PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 22s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m17s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 12s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 20s
A parent choosing a primary and a secondary school at once hit the old cap of five total. The basket now holds up to five primary and five secondary schools (ten in all), matching the compare page's phase tabs. Schools that could land in either tab (all-through, special schools with phase "Not applicable", unknown phase) count against both groups, so no tab ever exceeds the five-slot chart palette and point styles. - lib/compareLogic: compareGroups, fullGroupFor, fitToGroupLimits - search modal disables only the full group and says which one - rankings rows carry the phase of the tab they are ranked under - shared ?urns= links are trimmed per group - copy: compare metadata, homepage value prop, How it works card now name primary and secondary schools (also better for search intent) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
276 lines
9.0 KiB
TypeScript
276 lines
9.0 KiB
TypeScript
/**
|
||
* 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 { useSeriesColors, useThemeTokens } from '@/lib/theme';
|
||
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<string, ComparisonData>;
|
||
/** 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<number, number | null | undefined>;
|
||
/** KS4 metrics get a different (honest) gap caption than KS2. */
|
||
isSecondary?: boolean;
|
||
}
|
||
|
||
// One shape per slot in a phase tab (MAX_PER_GROUP = 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, isSecondary = false }: ComparisonChartProps) {
|
||
const isMobile = useIsMobile();
|
||
// Canvas can't resolve var(), so the series come through the token bridge.
|
||
// Same index order as CHART_COLORS, so a school's line and its DOM swatch
|
||
// stay the same colour.
|
||
const seriesColors = useSeriesColors();
|
||
const [cRef, cGrid, cInverse, cInverseText] = useThemeTokens(
|
||
'--chart-reference', '--chart-grid', '--surface-inverse', '--text-inverse'
|
||
);
|
||
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]);
|
||
|
||
if (schools.length === 0) {
|
||
return <div>No data available</div>;
|
||
}
|
||
|
||
// 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 = seriesColors[series.schoolIndex % seriesColors.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: cRef,
|
||
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<number | null>),
|
||
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: cInverse,
|
||
titleColor: cInverseText,
|
||
bodyColor: cInverseText,
|
||
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' : 'Score',
|
||
font: {
|
||
size: 12,
|
||
weight: 'bold',
|
||
},
|
||
},
|
||
...yBounds,
|
||
ticks: {
|
||
font: { size: isMobile ? 10 : 12 },
|
||
...(isMobile && { maxTicksLimit: 5 }),
|
||
},
|
||
grid: {
|
||
color: cGrid,
|
||
},
|
||
},
|
||
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 (
|
||
<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>
|
||
{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.
|
||
<p className={styles.chartNote}>
|
||
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 if available.
|
||
</p>
|
||
) : (
|
||
!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>
|
||
)
|
||
)}
|
||
</div>
|
||
);
|
||
}
|