SummerProject2026/dashboard/js/charts.js
2026-07-21 16:43:22 -06:00

288 lines
12 KiB
JavaScript

const SVG_NS = "http://www.w3.org/2000/svg";
function escapeText(value) {
return String(value)
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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",
`Development-sample quarterly aggregate non-pass rates, not population estimates. ${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",
`Observed development-sample non-pass rates by vehicle-age band, not population estimates. ${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",
`Supported development-sample cohort associations, not reliability rankings or population estimates. ${points
.map((point) => `${point.prior_make} ${point.prior_model}: ${percent(point.nonpass_rate)}`)
.join("; ")}`,
);
}
// Simplified from the Census 2025 1:20m cartographic boundary for Utah
// (GEOID 49). Utah's defining step is on the northeast edge, not both sides.
const UTAH_OUTLINE_PATH = "M44 20H153V67H226V255H44Z";
// Census county centers projected into the same Utah map frame.
const COUNTY_POINTS = {
cache: [128, 33],
weber: [123, 54],
davis: [111, 65],
"salt lake": [121, 83],
utah: [131, 108],
};
function normalizeCountyName(county) {
return String(county).trim().toLowerCase().replaceAll("_", " ").replace(/\s+/g, " ");
}
function displayCountyName(county) {
return county.replace(/\b\w/g, (letter) => letter.toUpperCase());
}
export function renderUtahCoverage(container, coveredCounties) {
const normalized = new Set(coveredCounties.map(normalizeCountyName));
const points = [...normalized]
.map((county) => ({ county, coordinates: COUNTY_POINTS[county] }))
.filter((item) => item.coordinates);
const countyNames = [...normalized].map(displayCountyName);
container.innerHTML = `
<svg viewBox="0 0 270 300" aria-hidden="true" focusable="false">
<path data-map-feature="utah-outline" d="${UTAH_OUTLINE_PATH}" fill="var(--gray-100)" stroke="var(--gray-300)" stroke-width="3" stroke-linejoin="round" vector-effect="non-scaling-stroke"></path>
${points
.map(
({ county, coordinates }) => `
<circle cx="${coordinates[0]}" cy="${coordinates[1]}" r="8" fill="var(--teal-700)" stroke="var(--paper)" stroke-width="3" vector-effect="non-scaling-stroke"><title>${escapeText(displayCountyName(county))} County feed available</title></circle>`,
)
.join("")}
<text x="135" y="205" text-anchor="middle" class="state-label">UTAH</text>
<text x="135" y="288" text-anchor="middle" class="axis-label">Participating feeds highlighted</text>
</svg>`;
container.setAttribute(
"aria-label",
countyNames.length
? `Outline of Utah showing development-sample feed availability for ${countyNames.join(", ")} County feeds. This is not an outcome ranking; other counties are unavailable.`
: "Outline of Utah. 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",
`Development-sample source-era coverage from ${years[0]} through ${years[years.length - 1]} for ${eras.join(", ")}.`,
);
}
export { aggregateOverview, compactNumber, percent };