123 lines
3.9 KiB
TypeScript
123 lines
3.9 KiB
TypeScript
'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 (
|
||
|
|
<div className={styles.wrapper}>
|
||
|
|
<Line data={data} options={options} />
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|