275 lines
12 KiB
JavaScript
275 lines
12 KiB
JavaScript
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",
|
|
`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("; ")}`,
|
|
);
|
|
}
|
|
|
|
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
|
|
? `Development-sample feed availability includes ${coveredCounties.join(", ")}. This is not an outcome ranking; 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",
|
|
`Development-sample source-era coverage from ${years[0]} through ${years[years.length - 1]} for ${eras.join(", ")}.`,
|
|
);
|
|
}
|
|
|
|
export { aggregateOverview, compactNumber, percent };
|