initial code
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
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 };
|
||||
@@ -0,0 +1,281 @@
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
function escapeText(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function percent(value, digits = 1) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "percent",
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function compactNumber(value) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
notation: value >= 10_000 ? "compact" : "standard",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function emptyChart(container, message) {
|
||||
container.innerHTML = `<div class="chart-empty"><p>${escapeText(message)}</p></div>`;
|
||||
container.setAttribute("aria-label", message);
|
||||
}
|
||||
|
||||
function aggregateOverview(rows) {
|
||||
const groups = new Map();
|
||||
for (const row of rows) {
|
||||
const key = `${row.year}-Q${row.quarter}`;
|
||||
const current = groups.get(key) ?? {
|
||||
label: `${row.year} Q${row.quarter}`,
|
||||
year: row.year,
|
||||
quarter: row.quarter,
|
||||
support: 0,
|
||||
weightedRisk: 0,
|
||||
};
|
||||
current.support += row.support_rounded;
|
||||
current.weightedRisk += row.support_rounded * row.nonpass_rate;
|
||||
groups.set(key, current);
|
||||
}
|
||||
return [...groups.values()]
|
||||
.map((group) => ({
|
||||
...group,
|
||||
risk: group.support > 0 ? group.weightedRisk / group.support : 0,
|
||||
}))
|
||||
.sort((left, right) => left.year - right.year || left.quarter - right.quarter);
|
||||
}
|
||||
|
||||
export function renderOutcomeTrend(container, rows) {
|
||||
const points = aggregateOverview(rows);
|
||||
if (points.length < 2) {
|
||||
emptyChart(container, "Not enough approved periods to draw a trend.");
|
||||
return;
|
||||
}
|
||||
const width = 920;
|
||||
const height = 330;
|
||||
const margin = { top: 24, right: 22, bottom: 54, left: 58 };
|
||||
const plotWidth = width - margin.left - margin.right;
|
||||
const plotHeight = height - margin.top - margin.bottom;
|
||||
const maxRisk = Math.max(0.05, ...points.map((point) => point.risk));
|
||||
const yMax = Math.min(1, Math.ceil(maxRisk * 20) / 20);
|
||||
const x = (index) => margin.left + (index / (points.length - 1)) * plotWidth;
|
||||
const y = (value) => margin.top + plotHeight - (value / yMax) * plotHeight;
|
||||
const path = points
|
||||
.map((point, index) => `${index === 0 ? "M" : "L"}${x(index).toFixed(1)},${y(point.risk).toFixed(1)}`)
|
||||
.join(" ");
|
||||
|
||||
const yTicks = Array.from({ length: 5 }, (_, index) => (index / 4) * yMax);
|
||||
const tickEvery = Math.max(1, Math.ceil(points.length / 8));
|
||||
const labelled = points.filter(
|
||||
(_point, index) => index % tickEvery === 0 || index === points.length - 1,
|
||||
);
|
||||
const description = points
|
||||
.map((point) => `${point.label}: ${percent(point.risk)}`)
|
||||
.join("; ");
|
||||
|
||||
container.innerHTML = `
|
||||
<svg viewBox="0 0 ${width} ${height}" aria-hidden="true" focusable="false">
|
||||
${yTicks
|
||||
.map(
|
||||
(tick) => `
|
||||
<line class="grid-line" x1="${margin.left}" x2="${width - margin.right}" y1="${y(tick)}" y2="${y(tick)}"></line>
|
||||
<text class="axis-label" x="${margin.left - 10}" y="${y(tick) + 4}" text-anchor="end">${percent(tick, 0)}</text>`,
|
||||
)
|
||||
.join("")}
|
||||
<line class="axis-line" x1="${margin.left}" x2="${width - margin.right}" y1="${height - margin.bottom}" y2="${height - margin.bottom}"></line>
|
||||
<path class="chart-line" d="${path}"></path>
|
||||
${points
|
||||
.map(
|
||||
(point, index) => `
|
||||
<circle class="chart-dot" cx="${x(index)}" cy="${y(point.risk)}" r="4">
|
||||
<title>${escapeText(point.label)}: ${percent(point.risk)} non-pass (${compactNumber(point.support)} rounded support)</title>
|
||||
</circle>`,
|
||||
)
|
||||
.join("")}
|
||||
${labelled
|
||||
.map((point) => {
|
||||
const index = points.indexOf(point);
|
||||
return `<text class="axis-label" x="${x(index)}" y="${height - 24}" text-anchor="middle">${escapeText(point.label.replace(" ", "\u00a0"))}</text>`;
|
||||
})
|
||||
.join("")}
|
||||
<text class="axis-label" transform="translate(16 ${height / 2}) rotate(-90)" text-anchor="middle">Non-pass rate</text>
|
||||
</svg>`;
|
||||
container.setAttribute(
|
||||
"aria-label",
|
||||
`Quarterly aggregate non-pass trend. ${description}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function renderAgeRisk(container, rows) {
|
||||
if (!rows.length) {
|
||||
emptyChart(container, "No supported vehicle-age bands are available.");
|
||||
return;
|
||||
}
|
||||
const points = [...rows];
|
||||
const width = 600;
|
||||
const height = 300;
|
||||
const margin = { top: 24, right: 20, bottom: 58, left: 52 };
|
||||
const plotWidth = width - margin.left - margin.right;
|
||||
const plotHeight = height - margin.top - margin.bottom;
|
||||
const maxRisk = Math.max(0.05, ...points.map((point) => point.nonpass_rate));
|
||||
const yMax = Math.min(1, Math.ceil(maxRisk * 20) / 20);
|
||||
const denominator = Math.max(1, points.length - 1);
|
||||
const x = (index) => margin.left + (index / denominator) * plotWidth;
|
||||
const y = (value) => margin.top + plotHeight - (value / yMax) * plotHeight;
|
||||
const path = points
|
||||
.map((point, index) => `${index === 0 ? "M" : "L"}${x(index)},${y(point.nonpass_rate)}`)
|
||||
.join(" ");
|
||||
|
||||
container.innerHTML = `
|
||||
<svg viewBox="0 0 ${width} ${height}" aria-hidden="true" focusable="false">
|
||||
${[0, 0.25, 0.5, 0.75, 1]
|
||||
.map((ratio) => {
|
||||
const tick = ratio * yMax;
|
||||
return `<line class="grid-line" x1="${margin.left}" x2="${width - margin.right}" y1="${y(tick)}" y2="${y(tick)}"></line>
|
||||
<text class="axis-label" x="${margin.left - 8}" y="${y(tick) + 4}" text-anchor="end">${percent(tick, 0)}</text>`;
|
||||
})
|
||||
.join("")}
|
||||
<path class="chart-line" d="${path}"></path>
|
||||
${points
|
||||
.map(
|
||||
(point, index) => `
|
||||
<circle class="chart-dot" cx="${x(index)}" cy="${y(point.nonpass_rate)}" r="5">
|
||||
<title>${escapeText(point.age_band)}: ${percent(point.nonpass_rate)} (${compactNumber(point.support_rounded)} rounded support)</title>
|
||||
</circle>
|
||||
<text class="axis-label" x="${x(index)}" y="${height - 25}" text-anchor="middle">${escapeText(point.age_band)}</text>`,
|
||||
)
|
||||
.join("")}
|
||||
</svg>`;
|
||||
container.setAttribute(
|
||||
"aria-label",
|
||||
`Non-pass risk by vehicle-age band. ${points
|
||||
.map((point) => `${point.age_band}: ${percent(point.nonpass_rate)}`)
|
||||
.join("; ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function renderCohortDotPlot(container, rows) {
|
||||
const points = rows.slice(0, 12);
|
||||
if (!points.length) {
|
||||
emptyChart(container, "No supported cohorts match the current filters.");
|
||||
return;
|
||||
}
|
||||
const width = 820;
|
||||
const rowHeight = 34;
|
||||
const margin = { top: 24, right: 60, bottom: 42, left: 220 };
|
||||
const height = margin.top + margin.bottom + points.length * rowHeight;
|
||||
const plotWidth = width - margin.left - margin.right;
|
||||
const maxRisk = Math.max(0.05, ...points.map((point) => point.nonpass_rate));
|
||||
const xMax = Math.min(1, Math.ceil(maxRisk * 20) / 20);
|
||||
const x = (value) => margin.left + (value / xMax) * plotWidth;
|
||||
|
||||
container.innerHTML = `
|
||||
<svg viewBox="0 0 ${width} ${height}" aria-hidden="true" focusable="false">
|
||||
${[0, 0.25, 0.5, 0.75, 1]
|
||||
.map((ratio) => {
|
||||
const value = ratio * xMax;
|
||||
return `<line class="grid-line" x1="${x(value)}" x2="${x(value)}" y1="${margin.top - 8}" y2="${height - margin.bottom + 5}"></line>
|
||||
<text class="axis-label" x="${x(value)}" y="${height - 15}" text-anchor="middle">${percent(value, 0)}</text>`;
|
||||
})
|
||||
.join("")}
|
||||
${points
|
||||
.map((point, index) => {
|
||||
const y = margin.top + index * rowHeight + rowHeight / 2;
|
||||
const label = `${point.prior_make} ${point.prior_model}`;
|
||||
return `
|
||||
<text class="axis-label" x="${margin.left - 12}" y="${y + 4}" text-anchor="end">${escapeText(label)}</text>
|
||||
<line x1="${margin.left}" x2="${x(point.nonpass_rate)}" y1="${y}" y2="${y}" stroke="var(--red-100)" stroke-width="8" stroke-linecap="round"></line>
|
||||
<circle class="chart-dot" cx="${x(point.nonpass_rate)}" cy="${y}" r="5"><title>${escapeText(label)}: ${percent(point.nonpass_rate)}, ${compactNumber(point.support_rounded)} rounded support</title></circle>
|
||||
<text class="axis-label" x="${x(point.nonpass_rate) + 10}" y="${y + 4}">${percent(point.nonpass_rate)}</text>`;
|
||||
})
|
||||
.join("")}
|
||||
</svg>`;
|
||||
container.setAttribute(
|
||||
"aria-label",
|
||||
`Ranked supported cohort non-pass risk. ${points
|
||||
.map((point) => `${point.prior_make} ${point.prior_model}: ${percent(point.nonpass_rate)}`)
|
||||
.join("; ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const COUNTY_POINTS = {
|
||||
cache: [154, 40],
|
||||
weber: [137, 86],
|
||||
davis: [126, 108],
|
||||
salt_lake: [133, 133],
|
||||
"salt lake": [133, 133],
|
||||
utah: [140, 170],
|
||||
};
|
||||
|
||||
export function renderUtahCoverage(container, coveredCounties) {
|
||||
const normalized = new Set(coveredCounties.map((county) => county.toLowerCase()));
|
||||
const points = [...normalized]
|
||||
.map((county) => ({ county, coordinates: COUNTY_POINTS[county] }))
|
||||
.filter((item) => item.coordinates);
|
||||
container.innerHTML = `
|
||||
<svg viewBox="0 0 270 300" aria-hidden="true" focusable="false">
|
||||
<path d="M79 18h101v54l18 18v183H52V116l27-27V18Z" fill="var(--gray-100)" stroke="var(--gray-300)" stroke-width="3"></path>
|
||||
<path d="M80 20h98v53l17 18v179H55V117l25-27V20Z" fill="none" stroke="var(--sand-200)" stroke-width="1.5" stroke-dasharray="4 5"></path>
|
||||
${points
|
||||
.map(
|
||||
({ county, coordinates }) => `
|
||||
<circle cx="${coordinates[0]}" cy="${coordinates[1]}" r="8" fill="var(--teal-700)" stroke="var(--paper)" stroke-width="3"><title>${escapeText(county.replace("_", " "))} feed available</title></circle>`,
|
||||
)
|
||||
.join("")}
|
||||
<text x="135" y="288" text-anchor="middle" class="axis-label">Participating feeds highlighted</text>
|
||||
</svg>`;
|
||||
container.setAttribute(
|
||||
"aria-label",
|
||||
coveredCounties.length
|
||||
? `Utah feed coverage includes ${coveredCounties.join(", ")}. Other counties are unavailable.`
|
||||
: "No county feed coverage is available.",
|
||||
);
|
||||
}
|
||||
|
||||
export function renderCoverageHeatmap(container, rows) {
|
||||
if (!rows.length) {
|
||||
emptyChart(container, "No source-era coverage rows are available.");
|
||||
return;
|
||||
}
|
||||
const years = [...new Set(rows.map((row) => row.year))].sort((a, b) => a - b);
|
||||
const eras = [...new Set(rows.map((row) => row.source_era))].sort();
|
||||
const lookup = new Map(rows.map((row) => [`${row.source_era}-${row.year}`, row]));
|
||||
const maxSupport = Math.max(1, ...rows.map((row) => row.support_rounded));
|
||||
const columns = `90px repeat(${years.length}, minmax(34px, 1fr))`;
|
||||
container.innerHTML = `
|
||||
<div class="heatmap-grid" style="grid-template-columns:${columns}">
|
||||
<span></span>${years.map((year) => `<span class="heatmap-label">${year}</span>`).join("")}
|
||||
${eras
|
||||
.map(
|
||||
(era) => `<span class="heatmap-label">${escapeText(era)}</span>${years
|
||||
.map((year) => {
|
||||
const row = lookup.get(`${era}-${year}`);
|
||||
if (!row) return `<span class="heatmap-cell" title="${escapeText(era)} ${year}: unavailable"></span>`;
|
||||
const alpha = 0.15 + 0.75 * Math.sqrt(row.support_rounded / maxSupport);
|
||||
return `<span class="heatmap-cell" style="background:rgb(25 116 119 / ${alpha.toFixed(2)})" title="${escapeText(era)} ${year}: ${compactNumber(row.support_rounded)} rounded support; ${percent(row.labeled_rate)} labeled"></span>`;
|
||||
})
|
||||
.join("")}`,
|
||||
)
|
||||
.join("")}
|
||||
</div>`;
|
||||
container.setAttribute(
|
||||
"aria-label",
|
||||
`Source-era coverage from ${years[0]} through ${years[years.length - 1]} for ${eras.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function renderNoCalibration(container) {
|
||||
emptyChart(
|
||||
container,
|
||||
"Calibration-bin data is not published in this development bundle. No calibration curve is shown.",
|
||||
);
|
||||
}
|
||||
|
||||
export { aggregateOverview, compactNumber, percent };
|
||||
@@ -0,0 +1,574 @@
|
||||
const SCHEMA_VERSION = "dashboard_data_v1";
|
||||
const APPROVED_PARTITIONS = Object.freeze(["train", "tune", "calibrate"]);
|
||||
const ENVELOPE_KEYS = Object.freeze([
|
||||
"development_preview",
|
||||
"population_estimate_allowed",
|
||||
"schema_version",
|
||||
]);
|
||||
|
||||
export const REQUIRED_ASSETS = Object.freeze({
|
||||
manifest: "data_manifest.json",
|
||||
overview: "overview_period_county.json",
|
||||
ageRisk: "age_risk_curve.json",
|
||||
scorecards: "cohort_scorecard.json",
|
||||
diagnostics: "model_diagnostics.json",
|
||||
coverage: "coverage_quality.json",
|
||||
filters: "filter_catalog.json",
|
||||
shaManifest: "sha256_manifest.json",
|
||||
});
|
||||
|
||||
const SENSITIVE_KEY_PARTS = new Set([
|
||||
"address",
|
||||
"certificate",
|
||||
"email",
|
||||
"internal",
|
||||
"ip",
|
||||
"owner",
|
||||
"pid",
|
||||
"plate",
|
||||
"raw",
|
||||
"session",
|
||||
"station",
|
||||
"token",
|
||||
"user",
|
||||
"vin",
|
||||
"zip",
|
||||
]);
|
||||
|
||||
const ASSET_VALIDATORS = {
|
||||
overview: (value) => validateRows(value, validateOverviewRow, "overview_period_county"),
|
||||
ageRisk: (value) => validateRows(value, validateAgeRiskRow, "age_risk_curve"),
|
||||
scorecards: (value) => validateRows(value, validateScorecardRow, "cohort_scorecard"),
|
||||
diagnostics: (value) => validateRows(value, validateDiagnosticRow, "model_diagnostics"),
|
||||
coverage: (value) => validateRows(value, validateCoverageRow, "coverage_quality"),
|
||||
filters: validateFilterCatalog,
|
||||
shaManifest: validateShaManifest,
|
||||
};
|
||||
|
||||
export class DataContractError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = "DataContractError";
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
function requirePlainObject(value, label) {
|
||||
if (!isPlainObject(value)) {
|
||||
throw new DataContractError(`${label} must be a JSON object.`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireExactKeys(value, expectedKeys, label) {
|
||||
const observed = Object.keys(value).sort();
|
||||
const expected = [...expectedKeys].sort();
|
||||
if (JSON.stringify(observed) !== JSON.stringify(expected)) {
|
||||
throw new DataContractError(`${label} does not match the approved field schema.`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireExactStringSet(value, expectedValues, label) {
|
||||
validateStringArray(value, label);
|
||||
const observed = [...value].sort();
|
||||
const expected = [...expectedValues].sort();
|
||||
if (JSON.stringify(observed) !== JSON.stringify(expected)) {
|
||||
throw new DataContractError(`${label} does not match the approved values.`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireBoolean(value, label) {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new DataContractError(`${label} must be true or false.`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireString(value, label) {
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
throw new DataContractError(`${label} must be a non-empty string.`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireSafeModelVersion(value, label) {
|
||||
requireString(value, label);
|
||||
if (!/^[A-Za-z0-9._-]{1,64}$/.test(value)) {
|
||||
throw new DataContractError(`${label} is not an approved model version.`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireNumber(value, label, { min = -Infinity, max = Infinity } = {}) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) {
|
||||
throw new DataContractError(`${label} must be a finite number in the approved range.`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireInteger(value, label, { min = -Infinity, max = Infinity } = {}) {
|
||||
requireNumber(value, label, { min, max });
|
||||
if (!Number.isInteger(value)) {
|
||||
throw new DataContractError(`${label} must be an integer.`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateEnvelope(value, label) {
|
||||
requirePlainObject(value, label);
|
||||
if (value.schema_version !== SCHEMA_VERSION) {
|
||||
throw new DataContractError(`${label} uses an unsupported schema version.`);
|
||||
}
|
||||
requireBoolean(value.development_preview, `${label}.development_preview`);
|
||||
requireBoolean(
|
||||
value.population_estimate_allowed,
|
||||
`${label}.population_estimate_allowed`,
|
||||
);
|
||||
rejectSensitiveKeys(value, label);
|
||||
}
|
||||
|
||||
function validateRows(value, rowValidator, label) {
|
||||
validateEnvelope(value, label);
|
||||
requireExactKeys(value, [...ENVELOPE_KEYS, "rows"], label);
|
||||
if (!Array.isArray(value.rows)) {
|
||||
throw new DataContractError(`${label}.rows must be an array.`);
|
||||
}
|
||||
value.rows.forEach((row, index) => {
|
||||
requirePlainObject(row, `${label}.rows[${index}]`);
|
||||
rowValidator(row, `${label}.rows[${index}]`);
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateOverviewRow(row, label) {
|
||||
requireExactKeys(
|
||||
row,
|
||||
["year", "quarter", "public_county", "support_rounded", "nonpass_rate"],
|
||||
label,
|
||||
);
|
||||
requireInteger(row.year, `${label}.year`, { min: 2010, max: 2100 });
|
||||
requireInteger(row.quarter, `${label}.quarter`, { min: 1, max: 4 });
|
||||
requireString(row.public_county, `${label}.public_county`);
|
||||
requireInteger(row.support_rounded, `${label}.support_rounded`, { min: 0 });
|
||||
requireNumber(row.nonpass_rate, `${label}.nonpass_rate`, { min: 0, max: 1 });
|
||||
}
|
||||
|
||||
function validateAgeRiskRow(row, label) {
|
||||
requireExactKeys(row, ["age_band", "support_rounded", "nonpass_rate"], label);
|
||||
requireString(row.age_band, `${label}.age_band`);
|
||||
requireInteger(row.support_rounded, `${label}.support_rounded`, { min: 0 });
|
||||
requireNumber(row.nonpass_rate, `${label}.nonpass_rate`, { min: 0, max: 1 });
|
||||
}
|
||||
|
||||
function validateScorecardRow(row, label) {
|
||||
requireExactKeys(
|
||||
row,
|
||||
["prior_make", "prior_model", "support_rounded", "nonpass_rate"],
|
||||
label,
|
||||
);
|
||||
requireString(row.prior_make, `${label}.prior_make`);
|
||||
requireString(row.prior_model, `${label}.prior_model`);
|
||||
requireInteger(row.support_rounded, `${label}.support_rounded`, { min: 0 });
|
||||
requireNumber(row.nonpass_rate, `${label}.nonpass_rate`, { min: 0, max: 1 });
|
||||
}
|
||||
|
||||
function validateDiagnosticRow(row, label) {
|
||||
requireExactKeys(
|
||||
row,
|
||||
[
|
||||
"model",
|
||||
"partition",
|
||||
"average_precision",
|
||||
"brier",
|
||||
"log_loss",
|
||||
"roc_auc",
|
||||
"top_10_capture",
|
||||
],
|
||||
label,
|
||||
);
|
||||
requireString(row.model, `${label}.model`);
|
||||
requireString(row.partition, `${label}.partition`);
|
||||
if (!APPROVED_PARTITIONS.includes(row.partition)) {
|
||||
throw new DataContractError(`${label}.partition is not approved for this public schema.`);
|
||||
}
|
||||
for (const metric of [
|
||||
"average_precision",
|
||||
"brier",
|
||||
"log_loss",
|
||||
"roc_auc",
|
||||
"top_10_capture",
|
||||
]) {
|
||||
requireNumber(row[metric], `${label}.${metric}`, { min: 0 });
|
||||
}
|
||||
if (row.average_precision > 1 || row.brier > 1 || row.roc_auc > 1 || row.top_10_capture > 1) {
|
||||
throw new DataContractError(`${label} contains a probability metric above 1.`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateCoverageRow(row, label) {
|
||||
requireExactKeys(
|
||||
row,
|
||||
[
|
||||
"year",
|
||||
"source_era",
|
||||
"support_rounded",
|
||||
"labeled_rate",
|
||||
"overall_result_share",
|
||||
"utah_obd_proxy_share",
|
||||
],
|
||||
label,
|
||||
);
|
||||
requireInteger(row.year, `${label}.year`, { min: 2010, max: 2100 });
|
||||
requireString(row.source_era, `${label}.source_era`);
|
||||
requireInteger(row.support_rounded, `${label}.support_rounded`, { min: 0 });
|
||||
for (const metric of ["labeled_rate", "overall_result_share", "utah_obd_proxy_share"]) {
|
||||
requireNumber(row[metric], `${label}.${metric}`, { min: 0, max: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
function validateStringArray(value, label) {
|
||||
if (!Array.isArray(value)) throw new DataContractError(`${label} must be an array.`);
|
||||
value.forEach((item, index) => requireString(item, `${label}[${index}]`));
|
||||
}
|
||||
|
||||
function validateFilterCatalog(value) {
|
||||
const label = "filter_catalog";
|
||||
validateEnvelope(value, label);
|
||||
requireExactKeys(
|
||||
value,
|
||||
[
|
||||
...ENVELOPE_KEYS,
|
||||
"public_counties",
|
||||
"age_bands",
|
||||
"prior_make_models",
|
||||
"periods",
|
||||
"models",
|
||||
"partitions",
|
||||
],
|
||||
label,
|
||||
);
|
||||
validateStringArray(value.public_counties, `${label}.public_counties`);
|
||||
validateStringArray(value.age_bands, `${label}.age_bands`);
|
||||
validateStringArray(value.models, `${label}.models`);
|
||||
requireExactStringSet(value.partitions, APPROVED_PARTITIONS, `${label}.partitions`);
|
||||
if (!Array.isArray(value.prior_make_models)) {
|
||||
throw new DataContractError(`${label}.prior_make_models must be an array.`);
|
||||
}
|
||||
value.prior_make_models.forEach((row, index) => {
|
||||
requirePlainObject(row, `${label}.prior_make_models[${index}]`);
|
||||
requireExactKeys(
|
||||
row,
|
||||
["prior_make", "prior_model"],
|
||||
`${label}.prior_make_models[${index}]`,
|
||||
);
|
||||
requireString(row.prior_make, `${label}.prior_make_models[${index}].prior_make`);
|
||||
requireString(row.prior_model, `${label}.prior_make_models[${index}].prior_model`);
|
||||
});
|
||||
if (!Array.isArray(value.periods)) {
|
||||
throw new DataContractError(`${label}.periods must be an array.`);
|
||||
}
|
||||
value.periods.forEach((period, index) => {
|
||||
requirePlainObject(period, `${label}.periods[${index}]`);
|
||||
requireExactKeys(period, ["year", "quarters"], `${label}.periods[${index}]`);
|
||||
requireInteger(period.year, `${label}.periods[${index}].year`, {
|
||||
min: 2010,
|
||||
max: 2100,
|
||||
});
|
||||
if (!Array.isArray(period.quarters) || period.quarters.length === 0) {
|
||||
throw new DataContractError(`${label}.periods[${index}].quarters must be non-empty.`);
|
||||
}
|
||||
period.quarters.forEach((quarter, quarterIndex) =>
|
||||
requireInteger(quarter, `${label}.periods[${index}].quarters[${quarterIndex}]`, {
|
||||
min: 1,
|
||||
max: 4,
|
||||
}),
|
||||
);
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateShaManifest(value) {
|
||||
const label = "sha256_manifest";
|
||||
validateEnvelope(value, label);
|
||||
requireExactKeys(value, [...ENVELOPE_KEYS, "files"], label);
|
||||
if (!Array.isArray(value.files)) {
|
||||
throw new DataContractError(`${label}.files must be an array.`);
|
||||
}
|
||||
const expectedNames = Object.entries(REQUIRED_ASSETS)
|
||||
.filter(([name]) => name !== "shaManifest")
|
||||
.map(([, filename]) => filename)
|
||||
.sort();
|
||||
const observedNames = [];
|
||||
for (const [index, file] of value.files.entries()) {
|
||||
requirePlainObject(file, `${label}.files[${index}]`);
|
||||
requireExactKeys(file, ["name", "sha256"], `${label}.files[${index}]`);
|
||||
requireString(file.name, `${label}.files[${index}].name`);
|
||||
requireString(file.sha256, `${label}.files[${index}].sha256`);
|
||||
if (!/^[0-9a-f]{64}$/.test(file.sha256)) {
|
||||
throw new DataContractError(`${label}.files[${index}].sha256 is invalid.`);
|
||||
}
|
||||
observedNames.push(file.name);
|
||||
}
|
||||
observedNames.sort();
|
||||
if (JSON.stringify(observedNames) !== JSON.stringify(expectedNames)) {
|
||||
throw new DataContractError(`${label} does not cover the exact approved asset set.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function rejectSensitiveKeys(value, label, seen = new WeakSet()) {
|
||||
if (value === null || typeof value !== "object") return;
|
||||
if (seen.has(value)) return;
|
||||
seen.add(value);
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => rejectSensitiveKeys(item, `${label}[${index}]`, seen));
|
||||
return;
|
||||
}
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
const normalizedParts = key.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
||||
if (normalizedParts.some((part) => SENSITIVE_KEY_PARTS.has(part))) {
|
||||
throw new DataContractError(`${label} contains a field outside the public data contract.`);
|
||||
}
|
||||
rejectSensitiveKeys(child, `${label}.${key}`, seen);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateAssetSet(rawAssets) {
|
||||
requirePlainObject(rawAssets, "asset set");
|
||||
requireExactKeys(rawAssets, Object.keys(REQUIRED_ASSETS), "asset set");
|
||||
const manifest = rawAssets.manifest;
|
||||
validateEnvelope(manifest, "data_manifest");
|
||||
requireExactKeys(
|
||||
manifest,
|
||||
[
|
||||
...ENVELOPE_KEYS,
|
||||
"assets",
|
||||
"data_scope",
|
||||
"definitions",
|
||||
"model_versions",
|
||||
"release_id",
|
||||
],
|
||||
"data_manifest",
|
||||
);
|
||||
if (Object.hasOwn(manifest, "rows")) {
|
||||
throw new DataContractError("data_manifest must not contain row data.");
|
||||
}
|
||||
requirePlainObject(manifest.data_scope, "data_manifest.data_scope");
|
||||
requirePlainObject(manifest.definitions, "data_manifest.definitions");
|
||||
requireString(manifest.release_id, "data_manifest.release_id");
|
||||
if (!/^[0-9a-f]{64}$/.test(manifest.release_id)) {
|
||||
throw new DataContractError("data_manifest.release_id is invalid.");
|
||||
}
|
||||
if (!Array.isArray(manifest.model_versions) || manifest.model_versions.length === 0) {
|
||||
throw new DataContractError("data_manifest.model_versions must be a non-empty array.");
|
||||
}
|
||||
manifest.model_versions.forEach((version, index) =>
|
||||
requireSafeModelVersion(version, `data_manifest.model_versions[${index}]`),
|
||||
);
|
||||
if (
|
||||
JSON.stringify(manifest.model_versions) !==
|
||||
JSON.stringify([...new Set(manifest.model_versions)].sort())
|
||||
) {
|
||||
throw new DataContractError("data_manifest.model_versions must be sorted and unique.");
|
||||
}
|
||||
requireExactKeys(
|
||||
manifest.data_scope,
|
||||
["first_year", "last_year", "model_names", "partitions"],
|
||||
"data_manifest.data_scope",
|
||||
);
|
||||
requireInteger(manifest.data_scope.first_year, "data_manifest.data_scope.first_year", {
|
||||
min: 2010,
|
||||
max: 2100,
|
||||
});
|
||||
requireInteger(manifest.data_scope.last_year, "data_manifest.data_scope.last_year", {
|
||||
min: manifest.data_scope.first_year,
|
||||
max: 2100,
|
||||
});
|
||||
validateStringArray(manifest.data_scope.model_names, "data_manifest.data_scope.model_names");
|
||||
requireExactStringSet(
|
||||
manifest.data_scope.partitions,
|
||||
APPROVED_PARTITIONS,
|
||||
"data_manifest.data_scope.partitions",
|
||||
);
|
||||
requireExactKeys(
|
||||
manifest.definitions,
|
||||
[
|
||||
"episode_gap_days",
|
||||
"locked_test_metrics_published",
|
||||
"support_rounding",
|
||||
"suppression_min_nonpass",
|
||||
"suppression_min_pass",
|
||||
"suppression_min_support",
|
||||
"suppression_min_distinct_vehicles",
|
||||
"suppression_min_distinct_pass_vehicles",
|
||||
"suppression_min_distinct_nonpass_vehicles",
|
||||
"target",
|
||||
],
|
||||
"data_manifest.definitions",
|
||||
);
|
||||
requireInteger(manifest.definitions.episode_gap_days, "data_manifest.definitions.episode_gap_days", {
|
||||
min: 1,
|
||||
});
|
||||
requireBoolean(
|
||||
manifest.definitions.locked_test_metrics_published,
|
||||
"data_manifest.definitions.locked_test_metrics_published",
|
||||
);
|
||||
if (manifest.definitions.locked_test_metrics_published !== false) {
|
||||
throw new DataContractError("Locked-test metrics are not approved for this public preview.");
|
||||
}
|
||||
for (const key of [
|
||||
"support_rounding",
|
||||
"suppression_min_nonpass",
|
||||
"suppression_min_pass",
|
||||
"suppression_min_support",
|
||||
"suppression_min_distinct_vehicles",
|
||||
"suppression_min_distinct_pass_vehicles",
|
||||
"suppression_min_distinct_nonpass_vehicles",
|
||||
]) {
|
||||
requireInteger(manifest.definitions[key], `data_manifest.definitions.${key}`, { min: 1 });
|
||||
}
|
||||
requireString(manifest.definitions.target, "data_manifest.definitions.target");
|
||||
if (!Array.isArray(manifest.assets)) {
|
||||
throw new DataContractError("data_manifest.assets must be an array.");
|
||||
}
|
||||
validateStringArray(manifest.assets, "data_manifest.assets");
|
||||
const expectedManifestAssets = Object.entries(REQUIRED_ASSETS)
|
||||
.filter(([name]) => !["manifest", "shaManifest"].includes(name))
|
||||
.map(([, filename]) => filename)
|
||||
.sort();
|
||||
const declaredAssets = [...manifest.assets].sort();
|
||||
if (JSON.stringify(declaredAssets) !== JSON.stringify(expectedManifestAssets)) {
|
||||
throw new DataContractError("data_manifest.assets does not match the approved asset set.");
|
||||
}
|
||||
|
||||
const validated = { manifest };
|
||||
for (const [name, validator] of Object.entries(ASSET_VALIDATORS)) {
|
||||
if (!Object.hasOwn(rawAssets, name)) {
|
||||
throw new DataContractError(`A required approved aggregate is missing: ${name}.`);
|
||||
}
|
||||
validated[name] = validator(rawAssets[name]);
|
||||
if (
|
||||
validated[name].development_preview !== manifest.development_preview ||
|
||||
validated[name].population_estimate_allowed !== manifest.population_estimate_allowed
|
||||
) {
|
||||
throw new DataContractError(`${name} publication flags disagree with data_manifest.`);
|
||||
}
|
||||
}
|
||||
requireExactStringSet(
|
||||
validated.filters.models,
|
||||
manifest.data_scope.model_names,
|
||||
"filter_catalog.models",
|
||||
);
|
||||
for (const [index, row] of validated.diagnostics.rows.entries()) {
|
||||
if (!validated.filters.models.includes(row.model)) {
|
||||
throw new DataContractError(`model_diagnostics.rows[${index}].model is not cataloged.`);
|
||||
}
|
||||
}
|
||||
const minimumSupport = manifest.definitions.suppression_min_support;
|
||||
const supportRounding = manifest.definitions.support_rounding;
|
||||
for (const name of ["overview", "ageRisk", "scorecards", "coverage"]) {
|
||||
for (const [index, row] of validated[name].rows.entries()) {
|
||||
if (
|
||||
row.support_rounded < minimumSupport ||
|
||||
row.support_rounded % supportRounding !== 0
|
||||
) {
|
||||
throw new DataContractError(
|
||||
`${name}.rows[${index}].support_rounded violates the publication thresholds.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.freeze(validated);
|
||||
}
|
||||
|
||||
async function fetchBytes(path, fetchImplementation) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImplementation(path, {
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
} catch {
|
||||
throw new DataContractError("Approved dashboard assets could not be reached.");
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new DataContractError("One or more approved dashboard assets are unavailable.");
|
||||
}
|
||||
try {
|
||||
return await response.arrayBuffer();
|
||||
} catch {
|
||||
throw new DataContractError("An approved dashboard asset could not be read safely.");
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonBytes(bytes, label) {
|
||||
if (typeof TextDecoder !== "function") {
|
||||
throw new DataContractError("This browser cannot decode dashboard assets safely.");
|
||||
}
|
||||
try {
|
||||
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
||||
} catch {
|
||||
throw new DataContractError(`${label} is not valid UTF-8 JSON.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function sha256Hex(bytes, cryptoImplementation) {
|
||||
if (!cryptoImplementation?.subtle || typeof cryptoImplementation.subtle.digest !== "function") {
|
||||
throw new DataContractError("This browser cannot verify dashboard asset integrity.");
|
||||
}
|
||||
let digest;
|
||||
try {
|
||||
digest = await cryptoImplementation.subtle.digest("SHA-256", bytes);
|
||||
} catch {
|
||||
throw new DataContractError("Dashboard asset integrity verification failed.");
|
||||
}
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export async function loadDashboardData({
|
||||
basePath = "./public/data/",
|
||||
fetchImplementation = globalThis.fetch,
|
||||
cryptoImplementation = globalThis.crypto,
|
||||
} = {}) {
|
||||
if (typeof fetchImplementation !== "function") {
|
||||
throw new DataContractError("This browser cannot load dashboard assets safely.");
|
||||
}
|
||||
const baseUrl = new URL(basePath, globalThis.location?.href ?? "http://local/");
|
||||
const shaBytes = await fetchBytes(
|
||||
new URL(REQUIRED_ASSETS.shaManifest, baseUrl),
|
||||
fetchImplementation,
|
||||
);
|
||||
const shaManifest = validateShaManifest(
|
||||
parseJsonBytes(shaBytes, "The checksum manifest"),
|
||||
);
|
||||
const expectedDigests = new Map(
|
||||
shaManifest.files.map((entry) => [entry.name, entry.sha256]),
|
||||
);
|
||||
const assetEntries = Object.entries(REQUIRED_ASSETS).filter(
|
||||
([name]) => name !== "shaManifest",
|
||||
);
|
||||
const rawEntries = await Promise.all(
|
||||
assetEntries.map(async ([name, filename]) => [
|
||||
name,
|
||||
filename,
|
||||
await fetchBytes(new URL(filename, baseUrl), fetchImplementation),
|
||||
]),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
rawEntries.map(async ([, filename, bytes]) => {
|
||||
const observed = await sha256Hex(bytes, cryptoImplementation);
|
||||
if (observed !== expectedDigests.get(filename)) {
|
||||
throw new DataContractError(`Integrity verification failed for ${filename}.`);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const parsedEntries = rawEntries.map(([name, filename, bytes]) => [
|
||||
name,
|
||||
parseJsonBytes(bytes, filename),
|
||||
]);
|
||||
parsedEntries.push(["shaManifest", shaManifest]);
|
||||
return validateAssetSet(Object.fromEntries(parsedEntries));
|
||||
}
|
||||
|
||||
export { SCHEMA_VERSION };
|
||||
Reference in New Issue
Block a user