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>
88 lines
2.6 KiB
TypeScript
88 lines
2.6 KiB
TypeScript
'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 0–100.
|
||
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>
|
||
);
|
||
}
|