'use client'; /** * CutoffTrendChart * The last distance offered, year by year. * * Two things this chart deliberately does not do. * * It does not join across gaps (`spanGaps: false`). A missing year is usually a * year the authority published nothing, and a line drawn through it would * assert a cut-off that was never measured. * * It does not plot the non-distance outcomes. A year in which the school was * not oversubscribed has no distance — that is the point of it — and giving it * a y-position would put a number on the axis that does not exist. The mockup * floated such years at the top of the plot; here they are a gap in the line * and a labelled row in the table underneath, which is where a reason can be * stated in words rather than implied by a coordinate. */ import { Line } from 'react-chartjs-2'; import { ChartOptions } from 'chart.js'; import '@/lib/chartSetup'; import { useThemeTokens, alpha } from '@/lib/theme'; import type { CutoffYearRow } from './school/lastDistanceOffered'; import styles from './AdmissionsTrendChart.module.css'; const METRES_PER_MILE = 1609.344; export default function CutoffTrendChart({ rows }: { rows: CutoffYearRow[] }) { // rows arrive newest-first; a time axis reads oldest-first. const axis = [...rows].reverse(); const published = axis.filter((r) => r.distanceM != null); if (published.length < 2) return null; const labels = axis.map((r) => String(r.year)); const values: (number | null)[] = axis.map((r) => r.distanceM != null ? r.distanceM / METRES_PER_MILE : null, ); const present = values.map((v, i) => (v != null ? i : -1)).filter((i) => i >= 0); const lastIdx = present[present.length - 1]; const numeric = values.filter((v): v is number => v != null); const lo = Math.min(...numeric); const hi = Math.max(...numeric); // Headroom proportional to the spread, with a floor so a flat series does not // collapse onto a single gridline and read as more precise than it is. const pad = Math.max(0.05, (hi - lo) * 0.25); const yMin = Math.max(0, lo - pad); const yMax = hi + pad; const [cBrand, cGrid, cInverse, cInverseText, cCard] = useThemeTokens( '--brand', '--chart-grid', '--surface-inverse', '--text-inverse', '--bg-card', ); const options: ChartOptions<'line'> = { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, layout: { padding: { top: 8 } }, plugins: { legend: { display: false }, title: { display: false }, tooltip: { backgroundColor: cInverse, titleColor: cInverseText, bodyColor: cInverseText, padding: 10, titleFont: { size: 12 }, bodyFont: { size: 12 }, callbacks: { label: (ctx) => ctx.parsed.y == null ? '' : `Last distance offered: ${ctx.parsed.y.toFixed(2)} miles`, }, }, }, scales: { y: { min: yMin, max: yMax, grid: { color: cGrid }, ticks: { font: { size: 11 }, maxTicksLimit: 5, callback: (v) => `${Number(v).toFixed(2)} mi`, }, }, x: { grid: { display: false }, ticks: { font: { size: 11 }, autoSkip: true, maxRotation: 0, autoSkipPadding: 16 }, }, }, }; const data = { labels, datasets: [ { label: 'Last distance offered', data: values, clip: false as const, spanGaps: false, borderColor: cBrand, backgroundColor: alpha('--brand', 0.10), borderWidth: 2.5, tension: 0.3, fill: true, pointRadius: values.map((v, i) => (v == null ? 0 : i === lastIdx ? 5 : 3)), pointBackgroundColor: cBrand, pointBorderColor: cCard, pointBorderWidth: values.map((_, i) => (i === lastIdx ? 2 : 0)), pointHoverRadius: 6, }, ], }; return (
); }