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:
Tudor
2026-07-13 23:54:58 +01:00
co-authored by Claude Fable 5
parent 2573cd2490
commit 519584f34b
6 changed files with 523 additions and 16 deletions
+102
View File
@@ -0,0 +1,102 @@
/**
* Pure series-building for the comparison trend chart, extracted from
* ComparisonChart so it is unit-testable without a canvas.
*
* Chart truthfulness rules (spec §8.1): every academic year between the
* first and last data point appears on the axis — cancelled test years
* (2019/20, 2020/21) and the unpublished 2021/22 school-level year render
* as real gaps, never as compressed time; school lines never bridge gaps.
*/
import type { ComparisonData } from './types';
/** 201819 → 201920 (academic-year arithmetic on YYYYYY codes). */
function nextAcademicYear(year: number): number {
const start = Math.floor(year / 100);
const end = year % 100;
return (start + 1) * 100 + (end + 1);
}
/** Every academic year from min(years) to max(years), inclusive. */
export function fillAcademicYears(years: number[]): number[] {
if (years.length === 0) return [];
const ints = [...new Set(years.map((y) => Math.trunc(y)))].sort((a, b) => a - b);
const out: number[] = [];
let y = ints[0];
const last = ints[ints.length - 1];
while (y <= last && out.length < 50) {
out.push(y);
y = nextAcademicYear(y);
}
return out;
}
export interface CompareChartSeries {
label: string;
data: Array<number | null>;
/** Index into CHART_COLORS / point styles. */
schoolIndex: number;
spanGaps: false;
}
export interface EnglandSeries {
label: 'England average';
data: Array<number | null>;
borderDash: [number, number];
spanGaps: false;
}
export interface CompareChart {
years: number[];
schoolDatasets: CompareChartSeries[];
englandDataset: EnglandSeries | null;
/** True when England published a 2021/22 figure but no school has one —
* the UI shows: "DfE didn't publish school-level figures for 2021/22". */
showUnpublished202122Note: boolean;
}
export function buildCompareChart(
comparisonData: Record<string, ComparisonData>,
schools: Array<{ urn: number; school_name: string }>,
metric: string,
nationalByYear?: Record<number, number | null | undefined>,
): CompareChart {
const rawYears = schools.flatMap(
(s) => comparisonData[String(s.urn)]?.yearly_data.map((d) => Math.trunc(d.year)) ?? [],
);
const years = fillAcademicYears(rawYears);
const schoolDatasets: CompareChartSeries[] = schools.map((school, schoolIndex) => {
const rows = comparisonData[String(school.urn)]?.yearly_data ?? [];
const byYear = new Map<number, Record<string, unknown>>();
for (const row of rows) byYear.set(Math.trunc(row.year), row as unknown as Record<string, unknown>);
return {
label: school.school_name,
data: years.map((year) => {
const v = byYear.get(year)?.[metric];
return typeof v === 'number' && !Number.isNaN(v) ? v : null;
}),
schoolIndex,
spanGaps: false,
};
});
let englandDataset: EnglandSeries | null = null;
if (nationalByYear) {
const data = years.map((year) => {
const v = nationalByYear[year];
return typeof v === 'number' && !Number.isNaN(v) ? v : null;
});
if (data.some((v) => v != null)) {
englandDataset = { label: 'England average', data, borderDash: [5, 4], spanGaps: false };
}
}
const idx202122 = years.indexOf(202122);
const showUnpublished202122Note =
idx202122 >= 0 &&
englandDataset?.data[idx202122] != null &&
schoolDatasets.every((ds) => ds.data[idx202122] == null);
return { years, schoolDatasets, englandDataset, showUnpublished202122Note };
}