fix(admissions): render trend with Chart.js instead of scaled SVG

The hand-rolled SVG sparkline used px font sizes inside a 520-wide viewBox
that stretched to the full card width, so labels ballooned ~4x and collided —
and with 10+ years of real data the per-point labels and year ticks
overlapped badly, while the oversized chart stretched the "this year" view.

Replace it with a Chart.js line chart (AdmissionsTrendChart) in a fixed
200px wrapper, matching PerformanceChart: responsive px fonts, auto-skipping
x ticks, auto-scaled y-axis clamped to 0-100, emphasised latest point.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tudor
2026-06-19 18:59:57 +01:00
co-authored by Claude Opus 4.8
parent 4de7e559e9
commit 368b9f2b59
5 changed files with 172 additions and 100 deletions
@@ -0,0 +1,10 @@
.wrapper {
width: 100%;
height: 200px;
}
@media (max-width: 640px) {
.wrapper {
height: 180px;
}
}
@@ -0,0 +1,87 @@
'use client';
/**
* AdmissionsTrendChart
* Compact line chart of the first-choice offer rate across admissions years.
* Renders nothing unless at least two years carry an offer-rate value.
*/
import { Line } from 'react-chartjs-2';
import { ChartOptions } from 'chart.js';
import '@/lib/chartSetup';
import { formatAcademicYear } from '@/lib/utils';
import type { SchoolAdmissions } from '@/lib/types';
import styles from './AdmissionsTrendChart.module.css';
export default function AdmissionsTrendChart({ history }: { history: SchoolAdmissions[] }) {
const pts = history.filter((h) => h.first_preference_offer_pct != null);
if (pts.length < 2) return null;
const labels = pts.map((p) => formatAcademicYear(p.year));
const values = pts.map((p) => p.first_preference_offer_pct as number);
const lastIdx = pts.length - 1;
// Auto-scale with headroom so variation is visible, clamped to 0100.
const lo = Math.min(...values);
const hi = Math.max(...values);
const padded = Math.max(5, Math.round((hi - lo) * 0.25));
const yMin = Math.max(0, Math.floor((lo - padded) / 5) * 5);
const yMax = Math.min(100, Math.ceil((hi + padded) / 5) * 5);
const options: ChartOptions<'line'> = {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { display: false },
title: { display: false },
tooltip: {
backgroundColor: 'rgba(26,22,18,0.92)',
padding: 10,
titleFont: { size: 12 },
bodyFont: { size: 12 },
callbacks: {
label: (ctx) => (ctx.parsed.y == null ? '' : `First-choice offers: ${Math.round(ctx.parsed.y)}%`),
},
},
},
scales: {
y: {
min: yMin,
max: yMax,
grid: { color: 'rgba(0,0,0,0.05)' },
ticks: { font: { size: 11 }, maxTicksLimit: 5, callback: (v) => `${v}%` },
},
x: {
grid: { display: false },
ticks: { font: { size: 11 }, autoSkip: true, maxRotation: 0, autoSkipPadding: 16 },
},
},
};
const data = {
labels,
datasets: [
{
label: 'First-choice offer rate',
data: values,
borderColor: '#e07256',
backgroundColor: 'rgba(224,114,86,0.10)',
borderWidth: 2.5,
tension: 0.3,
fill: true,
pointRadius: pts.map((_, i) => (i === lastIdx ? 5 : 3)),
pointBackgroundColor: '#e07256',
pointBorderColor: '#fff',
pointBorderWidth: pts.map((_, i) => (i === lastIdx ? 2 : 0)),
pointHoverRadius: 6,
},
],
};
return (
<div className={styles.wrapper}>
<Line data={data} options={options} />
</div>
);
}
@@ -1381,49 +1381,6 @@
margin-bottom: 0.5rem;
}
.admissionsChart {
width: 100%;
height: auto;
display: block;
}
.admissionsGrid {
stroke: var(--border-color, #e5dfd5);
stroke-width: 1;
}
.admissionsAxis {
font-size: 12.5px;
fill: var(--text-muted, #6d685f);
font-family: var(--font-dm-sans), "DM Sans", sans-serif;
}
.admissionsLine {
stroke: var(--accent-coral, #e07256);
stroke-width: 3;
}
.admissionsDot {
fill: var(--accent-coral, #e07256);
}
.admissionsDotLast {
fill: var(--accent-coral, #e07256);
stroke: var(--bg-card, #fff);
stroke-width: 2;
}
.admissionsPtLabel {
font-size: 13px;
font-weight: 700;
fill: var(--text-primary, #1a1612);
font-family: var(--font-dm-sans), "DM Sans", sans-serif;
}
.admissionsPtLabel[data-last="true"] {
fill: var(--accent-coral-dark, #c45a3f);
}
.admissionsTrendSummary {
font-size: 1rem;
color: var(--text-secondary, #5c564d);
+2 -57
View File
@@ -28,6 +28,7 @@ const PerformanceChart = dynamic(
{ ssr: false },
);
const SatsChart = dynamic(() => import('./SatsChart'), { ssr: false });
const AdmissionsTrendChart = dynamic(() => import('./AdmissionsTrendChart'), { ssr: false });
import { track, getNavigationSource } from '@/lib/analytics';
import styles from './SchoolDetailView.module.css';
@@ -58,62 +59,6 @@ function progressClass(val: number | null | undefined): string {
return '';
}
/**
* Compact SVG sparkline of the first-choice offer rate across admissions years.
* Renders nothing unless at least two years carry an offer-rate value.
*/
function OfferRateTrend({ history }: { history: SchoolAdmissions[] }) {
const pts = history
.filter((h) => h.first_preference_offer_pct != null)
.map((h) => ({ year: h.year, v: h.first_preference_offer_pct as number }));
if (pts.length < 2) return null;
const W = 520, H = 118;
const padL = 44, padR = 20, padT = 16, padB = 34;
const plotW = W - padL - padR, plotH = H - padT - padB;
const values = pts.map((p) => p.v);
let lo = Math.max(0, Math.floor(Math.min(...values) / 10) * 10);
let hi = Math.min(100, Math.ceil(Math.max(...values) / 10) * 10);
// Guarantee a minimum span so small year-to-year moves aren't exaggerated.
if (hi - lo < 30) {
hi = Math.min(100, lo + 30);
if (hi - lo < 30) lo = Math.max(0, hi - 30);
}
const x = (i: number) => padL + (plotW * i) / (pts.length - 1);
const y = (v: number) => padT + plotH * (1 - (v - lo) / (hi - lo));
const gridVals = [hi, Math.round((hi + lo) / 2), lo];
const polyline = pts.map((p, i) => `${x(i)},${y(p.v)}`).join(' ');
return (
<svg
className={styles.admissionsChart}
viewBox={`0 0 ${W} ${H}`}
role="img"
aria-label={`First-choice offer rate from ${formatAcademicYear(pts[0].year)} to ${formatAcademicYear(pts[pts.length - 1].year)}`}
>
{gridVals.map((gv) => (
<g key={gv}>
<line x1={padL} y1={y(gv)} x2={W - padR} y2={y(gv)} className={styles.admissionsGrid} />
<text x={padL - 8} y={y(gv) + 4} textAnchor="end" className={styles.admissionsAxis}>{gv}%</text>
</g>
))}
<polyline points={polyline} fill="none" className={styles.admissionsLine} strokeLinecap="round" strokeLinejoin="round" />
{pts.map((p, i) => {
const isLast = i === pts.length - 1;
return (
<g key={p.year}>
<circle cx={x(i)} cy={y(p.v)} r={isLast ? 6 : 5} className={isLast ? styles.admissionsDotLast : styles.admissionsDot} />
<text x={x(i)} y={y(p.v) - 9} textAnchor="middle" className={styles.admissionsPtLabel} data-last={isLast}>{Math.round(p.v)}%</text>
<text x={x(i)} y={H - 12} textAnchor="middle" className={styles.admissionsAxis}>{formatAcademicYear(p.year)}</text>
</g>
);
})}
</svg>
);
}
interface SchoolDetailViewProps {
schoolInfo: School;
yearlyData: SchoolResult[];
@@ -1003,7 +948,7 @@ export function SchoolDetailView({
{showAdmissionsTrend && (
<div className={styles.admissionsViewTrend} hidden={admissionsView !== 'trend'}>
<div className={styles.admissionsChartCap}>First-choice offer rate</div>
<OfferRateTrend history={admissionsHistory} />
<AdmissionsTrendChart history={admissionsHistory} />
<p className={styles.admissionsTrendSummary}>
This year ({formatAcademicYear(admissions.year)}),{' '}
{admissions.first_preference_applications != null && (