2026-06-19 18:59:57 +01:00
|
|
|
|
'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 },
|
2026-06-19 22:32:09 +01:00
|
|
|
|
// Headroom so a point sitting on the y-max ceiling (e.g. 100%) isn't
|
|
|
|
|
|
// clipped by the top of the plot area.
|
|
|
|
|
|
layout: { padding: { top: 8 } },
|
2026-06-19 18:59:57 +01:00
|
|
|
|
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,
|
2026-06-19 22:32:09 +01:00
|
|
|
|
clip: false as const,
|
2026-06-19 18:59:57 +01:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|