Files
school_compare/nextjs-app/components/ComparisonChart.tsx
TudorandClaude Fable 5 f3fa12806b
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 49s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 3m8s
fix(compare): expert should-fixes S1-S4, S6 — banded chips, P8 reason, KS4 gap caption, all-through framing, cohort sizes
S1: first-choice chip banded (More than half / About 1 in 3 / Over 1 in 4
missed out) so a 44%-offered grammar isn't understated by half.
S2: Progress 8 explains its absence for 2024/25+ cohorts (no KS2 baseline,
COVID) instead of a bare 'No data'.
S3: KS4 trend charts get their own honest gap caption (2019/20-2020/21
unpublished; later years not in our dataset yet); y-axis 'Value'→'Score';
buildCompareChart exposes englandOnlyYears.
S4: all-through schools labelled in chips, rail caption says 'N schools ·
<phase> view' for mixed baskets, whole-school roll no longer judged
against the single-phase median, community section carries an all-ages
caveat.
S6 (spec §8.5): disadvantaged attainment shows the cohort behind it
('of ~50 disadvantaged pupils').

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-17 17:41:12 +01:00

266 lines
8.5 KiB
TypeScript
Raw Permalink 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.
/**
* 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<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 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, isSecondary = false }: ComparisonChartProps) {
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]);
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 = 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<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: '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' : 'Score',
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 (
<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/202020/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&apos;t published (COVID
grading), and more recent years aren&apos;t in our dataset yet where lines break the
England average is shown where available.
</p>
) : (
!isSecondary &&
built.showUnpublished202122Note && (
<p className={styles.chartNote}>
No national tests were held in 2019/20 and 2020/21 (COVID), and DfE didn&apos;t publish
school-level figures for 2021/22 the England average is shown for that year.
</p>
)
)}
</div>
);
}