Files
school_compare/nextjs-app/components/AdmissionsTrendChart.tsx
T
TudorandClaude Opus 4.8 2de1c4e766
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 50s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
fix(admissions): stop 100% trend point being clipped at chart top
A first-choice offer rate sitting on the y-max ceiling (100%) was drawn
flush against the top of the plot area, clipping the point marker. Add 8px
top layout padding and clip:false so ceiling values render in full, without
introducing a misleading >100% tick.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 22:32:09 +01:00

92 lines
2.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 0100.
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 },
// 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,
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>
);
}