/** * ComparisonChart Component * 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. */ 'use client'; import { useEffect, useState } from 'react'; import { Line } from 'react-chartjs-2'; import { ChartOptions, ChartDataset, PointStyle } from 'chart.js'; import '@/lib/chartSetup'; import { buildCompareChart } from '@/lib/compareChartData'; import type { ComparisonData } from '@/lib/types'; 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'; interface ComparisonChartProps { comparisonData: Record; /** Ordered as displayed in the school cards, so colours match by index. */ schools: Array<{ urn: number; school_name: string }>; metric: string; metricLabel: string; /** Official England figure per academic year for this metric — renders a * dashed grey reference line when provided. */ nationalByYear?: Record; } // 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']; export function ComparisonChart({ comparisonData, schools, metric, metricLabel, nationalByYear }: ComparisonChartProps) { const isMobile = useIsMobile(); const [focusedUrn, setFocusedUrn] = useState(null); // A focused school that leaves the basket must not linger. const urnKey = schools.map((s) => s.urn).join(','); useEffect(() => { setFocusedUrn(null); }, [urnKey]); if (schools.length === 0) { return
No data available
; } // 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; const datasets: ChartDataset<'line'>[] = built.schoolDatasets.map((series) => { const school = schools[series.schoolIndex]; const color = CHART_COLORS[series.schoolIndex % CHART_COLORS.length]; const dimmed = focusedUrn !== null && focusedUrn !== school.urn; return { label: series.label, data: series.data, borderColor: dimmed ? rgbToRgba(color, 0.2) : color, backgroundColor: dimmed ? 'transparent' : rgbToRgba(color, 0.1), borderWidth: focusedUrn === school.urn ? 3 : dimmed ? 1.5 : 2, pointStyle: POINT_STYLES[series.schoolIndex % POINT_STYLES.length], pointRadius: dimmed ? 2 : isMobile ? 3 : 4, pointHoverRadius: isMobile ? 5 : 6, tension: 0.3, // Never bridge missing years — gaps are information (COVID // cancellations, unpublished 2021/22, schools that opened later). spanGaps: false, }; }); 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, }); } const chartData = { labels: years.map(formatAcademicYear), datasets, }; const kind = metricKind(metric); const yBounds = computeYBounds( datasets.flatMap((ds) => ds.data as Array), kind, ); const options: ChartOptions<'line'> = { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index' as const, intersect: false, }, plugins: { legend: { display: !isMobile, position: 'top' as const, labels: { usePointStyle: true, padding: 15, font: { size: 12, }, }, }, // No in-chart title: the section heading and metric selector above the // chart already state the metric. title: { display: false, }, tooltip: { backgroundColor: 'rgba(0, 0, 0, 0.8)', padding: isMobile ? 10 : 12, titleFont: { size: isMobile ? 12 : 14, }, bodyFont: { size: isMobile ? 11 : 13, }, usePointStyle: true, itemSort: (a, b) => (b.parsed.y ?? -Infinity) - (a.parsed.y ?? -Infinity), callbacks: { label: function (context) { let label = context.dataset.label || ''; if (label) { label += ': '; } if (context.parsed.y !== null) { label += context.parsed.y.toFixed(1) + (kind === 'percentage' ? '%' : ''); } else { label += 'N/A'; } return label; }, }, }, }, scales: { y: { type: 'linear' as const, display: true, title: { display: !isMobile, text: kind === 'percentage' ? 'Percentage (%)' : kind === 'progress' ? 'Progress Score' : 'Value', font: { size: 12, weight: 'bold', }, }, ...yBounds, ticks: { font: { size: isMobile ? 10 : 12 }, ...(isMobile && { maxTicksLimit: 5 }), }, grid: { color: 'rgba(0, 0, 0, 0.05)', }, }, x: { grid: { display: false, }, title: { display: !isMobile, text: 'Year', font: { size: 12, weight: 'bold', }, }, ticks: { font: { size: isMobile ? 10 : 12 }, ...(isMobile && { maxRotation: 0, autoSkip: true, maxTicksLimit: 4 }), }, }, }, }; const toggleFocus = (urn: number) => { const next = focusedUrn === urn ? null : urn; setFocusedUrn(next); if (next !== null) track('compare_focus_school', { urn: next }); }; return (
{/* Mobile legend + focus control; a single series needs no legend. */} {schools.length > 1 && (
{schools.map((school, index) => ( ))}
)}
{built.showUnpublished202122Note && (

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.

)}
); }