fix(admissions): render trend with Chart.js instead of scaled SVG
The hand-rolled SVG sparkline used px font sizes inside a 520-wide viewBox that stretched to the full card width, so labels ballooned ~4x and collided — and with 10+ years of real data the per-point labels and year ticks overlapped badly, while the oversized chart stretched the "this year" view. Replace it with a Chart.js line chart (AdmissionsTrendChart) in a fixed 200px wrapper, matching PerformanceChart: responsive px fonts, auto-skipping x ticks, auto-scaled y-axis clamped to 0-100, emphasised latest point. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,73 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Admissions trend — Chart.js render check</title>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.5.1/dist/chart.umd.min.js"></script>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Playfair+Display:wght@600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg-primary:#faf7f2;--bg-secondary:#f3ede4;--bg-card:#fff;
|
||||||
|
--text-primary:#1a1612;--text-secondary:#5c564d;--text-muted:#6d685f;
|
||||||
|
--accent-coral:#e07256;--border:#e5dfd5;--shadow:0 2px 8px rgba(26,22,18,.06);
|
||||||
|
}
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{font-family:'DM Sans',sans-serif;background:var(--bg-primary);color:var(--text-primary);padding:40px 20px}
|
||||||
|
.wrap{max-width:900px;margin:0 auto}
|
||||||
|
.card{background:#fff;border:1px solid var(--border);border-radius:16px;box-shadow:var(--shadow);padding:28px}
|
||||||
|
.head{display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;margin-bottom:1.25rem}
|
||||||
|
.title{font-family:'Playfair Display',serif;font-weight:600;font-size:22px}
|
||||||
|
.seg{display:inline-flex;background:var(--bg-secondary);border-radius:999px;padding:3px;gap:2px}
|
||||||
|
.seg button{border:none;background:none;cursor:pointer;font:inherit;font-size:13px;font-weight:600;color:var(--text-muted);padding:6px 14px;border-radius:999px}
|
||||||
|
.seg button[aria-pressed="true"]{background:#fff;color:var(--text-primary);box-shadow:var(--shadow)}
|
||||||
|
.cap{font-size:13px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--text-muted);margin-bottom:.5rem}
|
||||||
|
.chartWrap{width:100%;height:200px}
|
||||||
|
.summary{font-size:16px;color:var(--text-secondary);margin-top:1.25rem;padding-top:1.1rem;border-top:1px solid var(--border)}
|
||||||
|
.summary strong{color:var(--text-primary)}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="card">
|
||||||
|
<div class="head">
|
||||||
|
<div class="title">How Hard to Get Into This School</div>
|
||||||
|
<div class="seg"><button aria-pressed="false">This year</button><button aria-pressed="true">13-year trend</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="cap">First-choice offer rate</div>
|
||||||
|
<div class="chartWrap"><canvas id="c"></canvas></div>
|
||||||
|
<p class="summary">This year (2026/27), <strong>30</strong> families put it first for <strong>30</strong> places — 101 applications in total.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const labels = ['2014/15','2015/16','2016/17','2017/18','2018/19','2019/20','2020/21','2021/22','2022/23','2023/24','2024/25','2025/26','2026/27'];
|
||||||
|
const values = [64,74,63,59,45,69,66,93,100,100,90,64,80];
|
||||||
|
const lastIdx = values.length-1;
|
||||||
|
const lo=Math.min(...values),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);
|
||||||
|
new Chart(document.getElementById('c'),{
|
||||||
|
type:'line',
|
||||||
|
data:{labels,datasets:[{
|
||||||
|
label:'First-choice offer rate',data:values,
|
||||||
|
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,
|
||||||
|
}]},
|
||||||
|
options:{
|
||||||
|
responsive:true,maintainAspectRatio:false,
|
||||||
|
interaction:{mode:'index',intersect:false},
|
||||||
|
plugins:{legend:{display:false},title:{display:false},
|
||||||
|
tooltip:{backgroundColor:'rgba(26,22,18,0.92)',padding:10,callbacks:{label:ctx=>`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}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
.wrapper {
|
||||||
|
width: 100%;
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.wrapper {
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
'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 },
|
||||||
|
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,
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1381,49 +1381,6 @@
|
|||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admissionsChart {
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admissionsGrid {
|
|
||||||
stroke: var(--border-color, #e5dfd5);
|
|
||||||
stroke-width: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admissionsAxis {
|
|
||||||
font-size: 12.5px;
|
|
||||||
fill: var(--text-muted, #6d685f);
|
|
||||||
font-family: var(--font-dm-sans), "DM Sans", sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admissionsLine {
|
|
||||||
stroke: var(--accent-coral, #e07256);
|
|
||||||
stroke-width: 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admissionsDot {
|
|
||||||
fill: var(--accent-coral, #e07256);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admissionsDotLast {
|
|
||||||
fill: var(--accent-coral, #e07256);
|
|
||||||
stroke: var(--bg-card, #fff);
|
|
||||||
stroke-width: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admissionsPtLabel {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
fill: var(--text-primary, #1a1612);
|
|
||||||
font-family: var(--font-dm-sans), "DM Sans", sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admissionsPtLabel[data-last="true"] {
|
|
||||||
fill: var(--accent-coral-dark, #c45a3f);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admissionsTrendSummary {
|
.admissionsTrendSummary {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: var(--text-secondary, #5c564d);
|
color: var(--text-secondary, #5c564d);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const PerformanceChart = dynamic(
|
|||||||
{ ssr: false },
|
{ ssr: false },
|
||||||
);
|
);
|
||||||
const SatsChart = dynamic(() => import('./SatsChart'), { ssr: false });
|
const SatsChart = dynamic(() => import('./SatsChart'), { ssr: false });
|
||||||
|
const AdmissionsTrendChart = dynamic(() => import('./AdmissionsTrendChart'), { ssr: false });
|
||||||
import { track, getNavigationSource } from '@/lib/analytics';
|
import { track, getNavigationSource } from '@/lib/analytics';
|
||||||
import styles from './SchoolDetailView.module.css';
|
import styles from './SchoolDetailView.module.css';
|
||||||
|
|
||||||
@@ -58,62 +59,6 @@ function progressClass(val: number | null | undefined): string {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Compact SVG sparkline of the first-choice offer rate across admissions years.
|
|
||||||
* Renders nothing unless at least two years carry an offer-rate value.
|
|
||||||
*/
|
|
||||||
function OfferRateTrend({ history }: { history: SchoolAdmissions[] }) {
|
|
||||||
const pts = history
|
|
||||||
.filter((h) => h.first_preference_offer_pct != null)
|
|
||||||
.map((h) => ({ year: h.year, v: h.first_preference_offer_pct as number }));
|
|
||||||
if (pts.length < 2) return null;
|
|
||||||
|
|
||||||
const W = 520, H = 118;
|
|
||||||
const padL = 44, padR = 20, padT = 16, padB = 34;
|
|
||||||
const plotW = W - padL - padR, plotH = H - padT - padB;
|
|
||||||
|
|
||||||
const values = pts.map((p) => p.v);
|
|
||||||
let lo = Math.max(0, Math.floor(Math.min(...values) / 10) * 10);
|
|
||||||
let hi = Math.min(100, Math.ceil(Math.max(...values) / 10) * 10);
|
|
||||||
// Guarantee a minimum span so small year-to-year moves aren't exaggerated.
|
|
||||||
if (hi - lo < 30) {
|
|
||||||
hi = Math.min(100, lo + 30);
|
|
||||||
if (hi - lo < 30) lo = Math.max(0, hi - 30);
|
|
||||||
}
|
|
||||||
|
|
||||||
const x = (i: number) => padL + (plotW * i) / (pts.length - 1);
|
|
||||||
const y = (v: number) => padT + plotH * (1 - (v - lo) / (hi - lo));
|
|
||||||
const gridVals = [hi, Math.round((hi + lo) / 2), lo];
|
|
||||||
const polyline = pts.map((p, i) => `${x(i)},${y(p.v)}`).join(' ');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
className={styles.admissionsChart}
|
|
||||||
viewBox={`0 0 ${W} ${H}`}
|
|
||||||
role="img"
|
|
||||||
aria-label={`First-choice offer rate from ${formatAcademicYear(pts[0].year)} to ${formatAcademicYear(pts[pts.length - 1].year)}`}
|
|
||||||
>
|
|
||||||
{gridVals.map((gv) => (
|
|
||||||
<g key={gv}>
|
|
||||||
<line x1={padL} y1={y(gv)} x2={W - padR} y2={y(gv)} className={styles.admissionsGrid} />
|
|
||||||
<text x={padL - 8} y={y(gv) + 4} textAnchor="end" className={styles.admissionsAxis}>{gv}%</text>
|
|
||||||
</g>
|
|
||||||
))}
|
|
||||||
<polyline points={polyline} fill="none" className={styles.admissionsLine} strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
{pts.map((p, i) => {
|
|
||||||
const isLast = i === pts.length - 1;
|
|
||||||
return (
|
|
||||||
<g key={p.year}>
|
|
||||||
<circle cx={x(i)} cy={y(p.v)} r={isLast ? 6 : 5} className={isLast ? styles.admissionsDotLast : styles.admissionsDot} />
|
|
||||||
<text x={x(i)} y={y(p.v) - 9} textAnchor="middle" className={styles.admissionsPtLabel} data-last={isLast}>{Math.round(p.v)}%</text>
|
|
||||||
<text x={x(i)} y={H - 12} textAnchor="middle" className={styles.admissionsAxis}>{formatAcademicYear(p.year)}</text>
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SchoolDetailViewProps {
|
interface SchoolDetailViewProps {
|
||||||
schoolInfo: School;
|
schoolInfo: School;
|
||||||
yearlyData: SchoolResult[];
|
yearlyData: SchoolResult[];
|
||||||
@@ -1003,7 +948,7 @@ export function SchoolDetailView({
|
|||||||
{showAdmissionsTrend && (
|
{showAdmissionsTrend && (
|
||||||
<div className={styles.admissionsViewTrend} hidden={admissionsView !== 'trend'}>
|
<div className={styles.admissionsViewTrend} hidden={admissionsView !== 'trend'}>
|
||||||
<div className={styles.admissionsChartCap}>First-choice offer rate</div>
|
<div className={styles.admissionsChartCap}>First-choice offer rate</div>
|
||||||
<OfferRateTrend history={admissionsHistory} />
|
<AdmissionsTrendChart history={admissionsHistory} />
|
||||||
<p className={styles.admissionsTrendSummary}>
|
<p className={styles.admissionsTrendSummary}>
|
||||||
This year ({formatAcademicYear(admissions.year)}),{' '}
|
This year ({formatAcademicYear(admissions.year)}),{' '}
|
||||||
{admissions.first_preference_applications != null && (
|
{admissions.first_preference_applications != null && (
|
||||||
|
|||||||
Reference in New Issue
Block a user