'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 { fillAcademicYears } from '@/lib/compareChartData'; 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; // Gap-honest axis: every academic year between the first and last data point // appears, so a missing admissions year renders as a real gap (spanGaps:false) // rather than compressing time between distant years. const axisYears = fillAcademicYears(pts.map((p) => p.year)); const byYear = new Map(pts.map((p) => [p.year, p.first_preference_offer_pct as number])); const labels = axisYears.map(formatAcademicYear); const values: (number | null)[] = axisYears.map((y) => byYear.get(y) ?? null); const present = values .map((v, i) => (v != null ? i : -1)) .filter((i) => i >= 0); const lastIdx = present[present.length - 1]; // Auto-scale with headroom so variation is visible, clamped to 0–100. const numeric = values.filter((v): v is number => v != null); const lo = Math.min(...numeric); const hi = Math.max(...numeric); 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 }, // 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 } }, 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, clip: false as const, spanGaps: false, borderColor: '#e07256', backgroundColor: 'rgba(224,114,86,0.10)', borderWidth: 2.5, tension: 0.3, fill: true, pointRadius: values.map((_, i) => (i === lastIdx ? 5 : 3)), pointBackgroundColor: '#e07256', pointBorderColor: '#fff', pointBorderWidth: values.map((_, i) => (i === lastIdx ? 2 : 0)), pointHoverRadius: 6, }, ], }; return (
); }