2026-02-02 20:34:35 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* ComparisonChart Component
|
2026-07-05 22:02:26 +01:00
|
|
|
|
* Multi-school comparison chart using Chart.js.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Desktop: built-in legend (point-style markers double as per-school shapes).
|
|
|
|
|
|
* Mobile (≤640px): the in-chart legend and axis titles are dropped in favour
|
|
|
|
|
|
* of a chip row above the canvas; tapping a chip highlights that school's
|
|
|
|
|
|
* line and dims the rest. The y-axis auto-fits the data on all viewports so
|
|
|
|
|
|
* clustered schools stay distinguishable.
|
2026-02-02 20:34:35 +00:00
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
'use client';
|
|
|
|
|
|
|
2026-07-05 22:02:26 +01:00
|
|
|
|
import { useEffect, useState } from 'react';
|
2026-02-02 20:34:35 +00:00
|
|
|
|
import { Line } from 'react-chartjs-2';
|
2026-07-05 22:02:26 +01:00
|
|
|
|
import { ChartOptions, ChartDataset, PointStyle } from 'chart.js';
|
2026-04-15 22:45:46 +01:00
|
|
|
|
import '@/lib/chartSetup';
|
2026-07-13 23:54:20 +01:00
|
|
|
|
import { buildCompareChart } from '@/lib/compareChartData';
|
2026-02-02 20:34:35 +00:00
|
|
|
|
import type { ComparisonData } from '@/lib/types';
|
2026-07-05 22:02:26 +01:00
|
|
|
|
import {
|
|
|
|
|
|
CHART_COLORS,
|
|
|
|
|
|
CHART_TEXT_COLORS,
|
|
|
|
|
|
computeYBounds,
|
|
|
|
|
|
formatAcademicYear,
|
|
|
|
|
|
metricKind,
|
|
|
|
|
|
rgbToRgba,
|
|
|
|
|
|
} from '@/lib/utils';
|
|
|
|
|
|
import { useIsMobile } from '@/hooks/useIsMobile';
|
|
|
|
|
|
import { track } from '@/lib/analytics';
|
|
|
|
|
|
import styles from './ComparisonChart.module.css';
|
2026-02-02 20:34:35 +00:00
|
|
|
|
|
|
|
|
|
|
interface ComparisonChartProps {
|
|
|
|
|
|
comparisonData: Record<string, ComparisonData>;
|
2026-07-05 22:02:26 +01:00
|
|
|
|
/** Ordered as displayed in the school cards, so colours match by index. */
|
|
|
|
|
|
schools: Array<{ urn: number; school_name: string }>;
|
2026-02-02 20:34:35 +00:00
|
|
|
|
metric: string;
|
|
|
|
|
|
metricLabel: string;
|
2026-07-13 23:54:20 +01:00
|
|
|
|
/** Official England figure per academic year for this metric — renders a
|
|
|
|
|
|
* dashed grey reference line when provided. */
|
|
|
|
|
|
nationalByYear?: Record<number, number | null | undefined>;
|
2026-07-17 17:41:12 +01:00
|
|
|
|
/** KS4 metrics get a different (honest) gap caption than KS2. */
|
|
|
|
|
|
isSecondary?: boolean;
|
2026-02-02 20:34:35 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-05 22:02:26 +01:00
|
|
|
|
// One shape per basket slot (MAX_SCHOOLS = 5) — secondary encoding so
|
|
|
|
|
|
// converging lines stay tellable apart without relying on hue alone.
|
|
|
|
|
|
const POINT_STYLES: PointStyle[] = ['circle', 'triangle', 'rect', 'rectRot', 'star'];
|
|
|
|
|
|
|
2026-07-17 17:41:12 +01:00
|
|
|
|
export function ComparisonChart({ comparisonData, schools, metric, metricLabel, nationalByYear, isSecondary = false }: ComparisonChartProps) {
|
2026-07-05 22:02:26 +01:00
|
|
|
|
const isMobile = useIsMobile();
|
|
|
|
|
|
const [focusedUrn, setFocusedUrn] = useState<number | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
// A focused school that leaves the basket must not linger.
|
|
|
|
|
|
const urnKey = schools.map((s) => s.urn).join(',');
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setFocusedUrn(null);
|
|
|
|
|
|
}, [urnKey]);
|
2026-02-02 20:34:35 +00:00
|
|
|
|
|
|
|
|
|
|
if (schools.length === 0) {
|
|
|
|
|
|
return <div>No data available</div>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 23:54:20 +01:00
|
|
|
|
// Pure, tested series construction: union of years with cancelled /
|
|
|
|
|
|
// unpublished years kept as real gaps, plus the England overlay.
|
|
|
|
|
|
const built = buildCompareChart(comparisonData, schools, metric, nationalByYear);
|
|
|
|
|
|
const { years } = built;
|
2026-02-02 20:34:35 +00:00
|
|
|
|
|
2026-07-13 23:54:20 +01:00
|
|
|
|
const datasets: ChartDataset<'line'>[] = built.schoolDatasets.map((series) => {
|
|
|
|
|
|
const school = schools[series.schoolIndex];
|
|
|
|
|
|
const color = CHART_COLORS[series.schoolIndex % CHART_COLORS.length];
|
2026-07-05 22:02:26 +01:00
|
|
|
|
const dimmed = focusedUrn !== null && focusedUrn !== school.urn;
|
2026-02-02 20:34:35 +00:00
|
|
|
|
|
|
|
|
|
|
return {
|
2026-07-13 23:54:20 +01:00
|
|
|
|
label: series.label,
|
|
|
|
|
|
data: series.data,
|
2026-07-05 22:02:26 +01:00
|
|
|
|
borderColor: dimmed ? rgbToRgba(color, 0.2) : color,
|
|
|
|
|
|
backgroundColor: dimmed ? 'transparent' : rgbToRgba(color, 0.1),
|
|
|
|
|
|
borderWidth: focusedUrn === school.urn ? 3 : dimmed ? 1.5 : 2,
|
2026-07-13 23:54:20 +01:00
|
|
|
|
pointStyle: POINT_STYLES[series.schoolIndex % POINT_STYLES.length],
|
2026-07-05 22:02:26 +01:00
|
|
|
|
pointRadius: dimmed ? 2 : isMobile ? 3 : 4,
|
|
|
|
|
|
pointHoverRadius: isMobile ? 5 : 6,
|
2026-02-02 20:34:35 +00:00
|
|
|
|
tension: 0.3,
|
2026-07-13 23:54:20 +01:00
|
|
|
|
// Never bridge missing years — gaps are information (COVID
|
|
|
|
|
|
// cancellations, unpublished 2021/22, schools that opened later).
|
|
|
|
|
|
spanGaps: false,
|
2026-02-02 20:34:35 +00:00
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-13 23:54:20 +01:00
|
|
|
|
if (built.englandDataset) {
|
|
|
|
|
|
datasets.push({
|
|
|
|
|
|
label: built.englandDataset.label,
|
|
|
|
|
|
data: built.englandDataset.data,
|
|
|
|
|
|
borderColor: 'rgba(109, 104, 95, 0.9)',
|
|
|
|
|
|
backgroundColor: 'transparent',
|
|
|
|
|
|
borderWidth: 1.5,
|
|
|
|
|
|
borderDash: built.englandDataset.borderDash,
|
|
|
|
|
|
pointStyle: 'line',
|
|
|
|
|
|
pointRadius: 0,
|
|
|
|
|
|
pointHoverRadius: 4,
|
|
|
|
|
|
tension: 0,
|
|
|
|
|
|
spanGaps: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 20:34:35 +00:00
|
|
|
|
const chartData = {
|
2026-04-01 14:58:13 +01:00
|
|
|
|
labels: years.map(formatAcademicYear),
|
2026-02-02 20:34:35 +00:00
|
|
|
|
datasets,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-05 22:02:26 +01:00
|
|
|
|
const kind = metricKind(metric);
|
|
|
|
|
|
const yBounds = computeYBounds(
|
|
|
|
|
|
datasets.flatMap((ds) => ds.data as Array<number | null>),
|
|
|
|
|
|
kind,
|
|
|
|
|
|
);
|
2026-02-02 20:34:35 +00:00
|
|
|
|
|
|
|
|
|
|
const options: ChartOptions<'line'> = {
|
|
|
|
|
|
responsive: true,
|
|
|
|
|
|
maintainAspectRatio: false,
|
|
|
|
|
|
interaction: {
|
|
|
|
|
|
mode: 'index' as const,
|
|
|
|
|
|
intersect: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
plugins: {
|
|
|
|
|
|
legend: {
|
2026-07-05 22:02:26 +01:00
|
|
|
|
display: !isMobile,
|
2026-02-02 20:34:35 +00:00
|
|
|
|
position: 'top' as const,
|
|
|
|
|
|
labels: {
|
|
|
|
|
|
usePointStyle: true,
|
|
|
|
|
|
padding: 15,
|
|
|
|
|
|
font: {
|
|
|
|
|
|
size: 12,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
2026-07-05 22:02:26 +01:00
|
|
|
|
// No in-chart title: the section heading and metric selector above the
|
|
|
|
|
|
// chart already state the metric.
|
2026-02-02 20:34:35 +00:00
|
|
|
|
title: {
|
2026-07-05 22:02:26 +01:00
|
|
|
|
display: false,
|
2026-02-02 20:34:35 +00:00
|
|
|
|
},
|
|
|
|
|
|
tooltip: {
|
|
|
|
|
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
2026-07-05 22:02:26 +01:00
|
|
|
|
padding: isMobile ? 10 : 12,
|
2026-02-02 20:34:35 +00:00
|
|
|
|
titleFont: {
|
2026-07-05 22:02:26 +01:00
|
|
|
|
size: isMobile ? 12 : 14,
|
2026-02-02 20:34:35 +00:00
|
|
|
|
},
|
|
|
|
|
|
bodyFont: {
|
2026-07-05 22:02:26 +01:00
|
|
|
|
size: isMobile ? 11 : 13,
|
2026-02-02 20:34:35 +00:00
|
|
|
|
},
|
2026-07-05 22:02:26 +01:00
|
|
|
|
usePointStyle: true,
|
|
|
|
|
|
itemSort: (a, b) => (b.parsed.y ?? -Infinity) - (a.parsed.y ?? -Infinity),
|
2026-02-02 20:34:35 +00:00
|
|
|
|
callbacks: {
|
|
|
|
|
|
label: function (context) {
|
|
|
|
|
|
let label = context.dataset.label || '';
|
|
|
|
|
|
if (label) {
|
|
|
|
|
|
label += ': ';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (context.parsed.y !== null) {
|
2026-07-05 22:02:26 +01:00
|
|
|
|
label += context.parsed.y.toFixed(1) + (kind === 'percentage' ? '%' : '');
|
2026-02-02 20:34:35 +00:00
|
|
|
|
} else {
|
|
|
|
|
|
label += 'N/A';
|
|
|
|
|
|
}
|
|
|
|
|
|
return label;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
scales: {
|
|
|
|
|
|
y: {
|
|
|
|
|
|
type: 'linear' as const,
|
|
|
|
|
|
display: true,
|
|
|
|
|
|
title: {
|
2026-07-05 22:02:26 +01:00
|
|
|
|
display: !isMobile,
|
2026-07-17 17:41:12 +01:00
|
|
|
|
text: kind === 'percentage' ? 'Percentage (%)' : kind === 'progress' ? 'Progress Score' : 'Score',
|
2026-02-02 20:34:35 +00:00
|
|
|
|
font: {
|
|
|
|
|
|
size: 12,
|
|
|
|
|
|
weight: 'bold',
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
2026-07-05 22:02:26 +01:00
|
|
|
|
...yBounds,
|
|
|
|
|
|
ticks: {
|
|
|
|
|
|
font: { size: isMobile ? 10 : 12 },
|
|
|
|
|
|
...(isMobile && { maxTicksLimit: 5 }),
|
|
|
|
|
|
},
|
2026-02-02 20:34:35 +00:00
|
|
|
|
grid: {
|
|
|
|
|
|
color: 'rgba(0, 0, 0, 0.05)',
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
x: {
|
|
|
|
|
|
grid: {
|
|
|
|
|
|
display: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
title: {
|
2026-07-05 22:02:26 +01:00
|
|
|
|
display: !isMobile,
|
2026-02-02 20:34:35 +00:00
|
|
|
|
text: 'Year',
|
|
|
|
|
|
font: {
|
|
|
|
|
|
size: 12,
|
|
|
|
|
|
weight: 'bold',
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
2026-07-05 22:02:26 +01:00
|
|
|
|
ticks: {
|
|
|
|
|
|
font: { size: isMobile ? 10 : 12 },
|
|
|
|
|
|
...(isMobile && { maxRotation: 0, autoSkip: true, maxTicksLimit: 4 }),
|
|
|
|
|
|
},
|
2026-02-02 20:34:35 +00:00
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-05 22:02:26 +01:00
|
|
|
|
const toggleFocus = (urn: number) => {
|
|
|
|
|
|
const next = focusedUrn === urn ? null : urn;
|
|
|
|
|
|
setFocusedUrn(next);
|
|
|
|
|
|
if (next !== null) track('compare_focus_school', { urn: next });
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className={styles.wrapper}>
|
|
|
|
|
|
{/* Mobile legend + focus control; a single series needs no legend. */}
|
|
|
|
|
|
{schools.length > 1 && (
|
|
|
|
|
|
<div className={styles.chips} role="group" aria-label="Highlight a school on the chart">
|
|
|
|
|
|
{schools.map((school, index) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={school.urn}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={styles.chip}
|
|
|
|
|
|
aria-pressed={focusedUrn === school.urn}
|
|
|
|
|
|
onClick={() => toggleFocus(school.urn)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span
|
|
|
|
|
|
className={styles.chipDot}
|
|
|
|
|
|
style={{ background: CHART_COLORS[index % CHART_COLORS.length] }}
|
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span
|
|
|
|
|
|
className={styles.chipName}
|
|
|
|
|
|
style={{ color: CHART_TEXT_COLORS[index % CHART_TEXT_COLORS.length] }}
|
|
|
|
|
|
>
|
|
|
|
|
|
{school.school_name}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div className={styles.canvasBox}>
|
|
|
|
|
|
<Line data={chartData} options={options} aria-label={`${metricLabel} comparison chart`} />
|
|
|
|
|
|
</div>
|
2026-07-17 17:41:12 +01:00
|
|
|
|
{isSecondary && built.englandOnlyYears.length > 0 ? (
|
|
|
|
|
|
// KS4's honest story differs from KS2's: 2019/20–2020/21 school-level
|
|
|
|
|
|
// GCSE results weren't published (COVID grading); later years WERE
|
|
|
|
|
|
// published by DfE but aren't in our dataset yet.
|
2026-07-13 23:54:20 +01:00
|
|
|
|
<p className={styles.chartNote}>
|
2026-07-17 17:41:12 +01:00
|
|
|
|
School-level GCSE figures for 2019/20 and 2020/21 weren't published (COVID
|
|
|
|
|
|
grading), and more recent years aren't in our dataset yet where lines break — the
|
|
|
|
|
|
England average is shown where available.
|
2026-07-13 23:54:20 +01:00
|
|
|
|
</p>
|
2026-07-17 17:41:12 +01:00
|
|
|
|
) : (
|
|
|
|
|
|
!isSecondary &&
|
|
|
|
|
|
built.showUnpublished202122Note && (
|
|
|
|
|
|
<p className={styles.chartNote}>
|
|
|
|
|
|
No national tests were held in 2019/20 and 2020/21 (COVID), and DfE didn't publish
|
|
|
|
|
|
school-level figures for 2021/22 — the England average is shown for that year.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)
|
2026-07-13 23:54:20 +01:00
|
|
|
|
)}
|
2026-07-05 22:02:26 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
2026-02-02 20:34:35 +00:00
|
|
|
|
}
|