feat(compare): trends explorer with England line; gap-honest axis; series regression guard
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
This commit is contained in:
@@ -65,3 +65,9 @@
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.chartNote {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ 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,
|
||||
@@ -34,13 +35,16 @@ interface ComparisonChartProps {
|
||||
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>;
|
||||
}
|
||||
|
||||
// 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 }: ComparisonChartProps) {
|
||||
export function ComparisonChart({ comparisonData, schools, metric, metricLabel, nationalByYear }: ComparisonChartProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const [focusedUrn, setFocusedUrn] = useState<number | null>(null);
|
||||
|
||||
@@ -54,34 +58,48 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel }
|
||||
return <div>No data available</div>;
|
||||
}
|
||||
|
||||
// Union of years across all schools — coverage differs between them.
|
||||
const years = [
|
||||
...new Set(schools.flatMap((s) => comparisonData[String(s.urn)]?.yearly_data.map((d) => d.year) ?? [])),
|
||||
].sort((a, b) => a - b);
|
||||
// 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'>[] = schools.map((school, index) => {
|
||||
const data = comparisonData[String(school.urn)];
|
||||
const color = CHART_COLORS[index % CHART_COLORS.length];
|
||||
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: school.school_name,
|
||||
data: years.map((year) => {
|
||||
const yearData = data?.yearly_data.find((d) => d.year === year);
|
||||
if (!yearData) return null;
|
||||
return yearData[metric as keyof typeof yearData] as number | null;
|
||||
}),
|
||||
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[index % POINT_STYLES.length],
|
||||
pointStyle: POINT_STYLES[series.schoolIndex % POINT_STYLES.length],
|
||||
pointRadius: dimmed ? 2 : isMobile ? 3 : 4,
|
||||
pointHoverRadius: isMobile ? 5 : 6,
|
||||
tension: 0.3,
|
||||
spanGaps: true,
|
||||
// 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,
|
||||
@@ -222,6 +240,12 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel }
|
||||
<div className={styles.canvasBox}>
|
||||
<Line data={chartData} options={options} aria-label={`${metricLabel} comparison chart`} />
|
||||
</div>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
.explore {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.explore summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--accent-coral-dark);
|
||||
padding: 0.85rem 1.1rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.explore[open] summary {
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.inner {
|
||||
border: 1px solid var(--border-light);
|
||||
border-top: none;
|
||||
border-radius: 0 0 8px 8px;
|
||||
background: var(--bg-card);
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
|
||||
.picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.picker label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.picker select {
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-light);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.progressNote {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.chartBox {
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.tableWrapper {
|
||||
overflow-x: auto;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
text-align: left;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.table th {
|
||||
background: var(--bg-secondary);
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.yearCell {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Explore trends — the full grouped metric catalogue (nothing from the old
|
||||
* compare page is lost; spec §4's tier 3) driving the year-by-year chart
|
||||
* with its England reference line, plus the year-by-year table. Progress
|
||||
* metrics carry CI-based bands for the years DfE published them.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
import { progressBand } from '@/lib/compareLogic';
|
||||
import type { ComparisonData, MetricDefinition, NationalAverages, School } from '@/lib/types';
|
||||
import { formatAcademicYear, formatMetricValue, metricKind } from '@/lib/utils';
|
||||
import { track } from '@/lib/analytics';
|
||||
import { Chip, Section, sectionStyles as s } from './sectionShared';
|
||||
import styles from './TrendsExplorer.module.css';
|
||||
|
||||
const ComparisonChart = dynamic(
|
||||
() => import('../ComparisonChart').then((m) => m.ComparisonChart),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const PRIMARY_OPTGROUPS: { label: string; category: string }[] = [
|
||||
{ label: 'Expected Standard', category: 'expected' },
|
||||
{ label: 'Higher Standard', category: 'higher' },
|
||||
{ label: 'Progress Scores', category: 'progress' },
|
||||
{ label: 'Average Scores', category: 'average' },
|
||||
{ label: 'Gender Performance', category: 'gender' },
|
||||
{ label: 'Equity (Disadvantaged)', category: 'equity' },
|
||||
{ label: 'School Context', category: 'context' },
|
||||
{ label: 'Absence', category: 'absence' },
|
||||
{ label: '3-Year Trends', category: 'trends' },
|
||||
];
|
||||
|
||||
const SECONDARY_OPTGROUPS: { label: string; category: string }[] = [
|
||||
{ label: 'GCSE Performance', category: 'gcse' },
|
||||
];
|
||||
|
||||
export const PRIMARY_CATEGORIES = PRIMARY_OPTGROUPS.map((g) => g.category);
|
||||
export const SECONDARY_CATEGORIES = SECONDARY_OPTGROUPS.map((g) => g.category);
|
||||
|
||||
const PROGRESS_CI: Record<string, [string, string]> = {
|
||||
reading_progress: ['reading_progress_lower_ci', 'reading_progress_upper_ci'],
|
||||
writing_progress: ['writing_progress_lower_ci', 'writing_progress_upper_ci'],
|
||||
maths_progress: ['maths_progress_lower_ci', 'maths_progress_upper_ci'],
|
||||
};
|
||||
|
||||
const BAND_LABEL = { above: 'Above average', average: 'Average', below: 'Below average' } as const;
|
||||
|
||||
export function TrendsExplorer({
|
||||
schools,
|
||||
data,
|
||||
metrics,
|
||||
initialMetric,
|
||||
isPrimaryPhase,
|
||||
nationalAverages,
|
||||
}: {
|
||||
schools: School[];
|
||||
data: Record<string, ComparisonData>;
|
||||
metrics: MetricDefinition[];
|
||||
initialMetric: string;
|
||||
isPrimaryPhase: boolean;
|
||||
nationalAverages?: NationalAverages;
|
||||
}) {
|
||||
const [metric, setMetric] = useState(initialMetric);
|
||||
|
||||
const allowedCategories = isPrimaryPhase ? PRIMARY_CATEGORIES : SECONDARY_CATEGORIES;
|
||||
const optgroups = isPrimaryPhase ? PRIMARY_OPTGROUPS : SECONDARY_OPTGROUPS;
|
||||
const filteredMetrics = metrics.filter((m) => allowedCategories.includes(m.category));
|
||||
const metricDef = metrics.find((m) => m.key === metric);
|
||||
const metricLabel = metricDef?.label || metric;
|
||||
|
||||
const nationalByYear: Record<number, number | null> = {};
|
||||
for (const entry of nationalAverages?.by_year ?? []) {
|
||||
const block = isPrimaryPhase ? entry.primary : entry.secondary;
|
||||
nationalByYear[entry.year] = block?.[metric] ?? null;
|
||||
}
|
||||
|
||||
const years = [
|
||||
...new Set(
|
||||
schools.flatMap(
|
||||
(school) => data[String(school.urn)]?.yearly_data.map((d) => Math.trunc(d.year)) ?? [],
|
||||
),
|
||||
),
|
||||
].sort((a, b) => a - b);
|
||||
|
||||
const handleMetricChange = (next: string) => {
|
||||
track('compare_metric_changed', { metric: next, phase: isPrimaryPhase ? 'primary' : 'secondary' });
|
||||
setMetric(next);
|
||||
};
|
||||
|
||||
const ciKeys = PROGRESS_CI[metric];
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Explore trends"
|
||||
how="The full year-by-year explorer for any measure, with the England average as a dashed reference line where official figures exist."
|
||||
>
|
||||
<details className={styles.explore} open>
|
||||
<summary>Year-by-year trends</summary>
|
||||
<div className={styles.inner}>
|
||||
<div className={styles.picker}>
|
||||
<label htmlFor="trends-metric-select">Measure:</label>
|
||||
<select
|
||||
id="trends-metric-select"
|
||||
value={metric}
|
||||
onChange={(e) => handleMetricChange(e.target.value)}
|
||||
>
|
||||
{optgroups.map(({ label, category }) => {
|
||||
const group = filteredMetrics.filter((m) => m.category === category);
|
||||
if (group.length === 0) return null;
|
||||
return (
|
||||
<optgroup key={category} label={label}>
|
||||
{group.map((m) => (
|
||||
<option key={m.key} value={m.key}>
|
||||
{m.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
{metricDef?.description && <span className={styles.desc}>{metricDef.description}</span>}
|
||||
</div>
|
||||
|
||||
{metric.includes('progress') && (
|
||||
<p className={styles.progressNote}>
|
||||
Progress scores measure pupils' progress from KS1 to KS2. A score of 0 equals the
|
||||
national average. DfE stopped publishing KS2 progress after 2022/23 (no KS1 baseline);
|
||||
bands use DfE's confidence intervals, not the raw score alone.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={styles.chartBox}>
|
||||
<ComparisonChart
|
||||
comparisonData={data}
|
||||
schools={schools}
|
||||
metric={metric}
|
||||
metricLabel={metricLabel}
|
||||
nationalByYear={nationalByYear}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{years.length > 0 && (
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Year</th>
|
||||
{schools.map((school) => (
|
||||
<th key={school.urn}>{school.school_name}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{years.map((year) => (
|
||||
<tr key={year}>
|
||||
<td className={styles.yearCell}>{formatAcademicYear(year)}</td>
|
||||
{schools.map((school) => {
|
||||
const row = data[String(school.urn)]?.yearly_data.find(
|
||||
(d) => Math.trunc(d.year) === year,
|
||||
) as (Record<string, unknown> & { year: number }) | undefined;
|
||||
const value = row?.[metric];
|
||||
if (typeof value !== 'number') return <td key={school.urn}>–</td>;
|
||||
const band = ciKeys
|
||||
? progressBand(
|
||||
value,
|
||||
(row?.[ciKeys[0]] as number | null) ?? null,
|
||||
(row?.[ciKeys[1]] as number | null) ?? null,
|
||||
)
|
||||
: null;
|
||||
return (
|
||||
<td key={school.urn}>
|
||||
{formatMetricValue(value, metricKind(metric))}{' '}
|
||||
{band && (
|
||||
<Chip tone={band === 'above' ? 'good' : band === 'below' ? 'warn' : 'neutral'}>
|
||||
{BAND_LABEL[band]}
|
||||
</Chip>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user