feat(compare): readable comparison chart on mobile
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m36s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m49s

The compare chart squashed clustered schools into a thin band (y pinned
0-100) under an in-chart title + per-school legend that ate ~40% of a
300px card, leaving converging lines indistinguishable on phones.

- Auto-fit the y-axis to the data on all viewports (computeYBounds in
  lib/utils: padded + min-span for percentages, symmetric around 0 for
  progress, fitted for scores; negative pct-named trend metrics are not
  zero-clamped).
- Distinct point style per school (circle/triangle/rect/rectRot/star)
  as secondary encoding for convergence and colour-blindness.
- Mobile: drop in-chart title/legend/axis titles; add a chip row (colour
  dot + name) that doubles as tap-to-focus — highlights one school's
  line and dims the rest. Chart card 300px -> 340px, nearly all plot.
- Fix a latent colour mismatch: datasets were built from Object.entries
  whose integer-like URN keys enumerate in ascending numeric order,
  desyncing line colours from card colours; the chart now receives the
  ordered school list.
- Union years across schools instead of taking the first school's.
- Extract PerformanceChart's matchMedia pattern into hooks/useIsMobile.

Unit tests for metricKind/computeYBounds; e2e journey covers the mobile
chips and focus toggle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Tudor
2026-07-05 22:02:26 +01:00
co-authored by Claude Fable 5
parent 90f2a02e75
commit 22769b6295
10 changed files with 341 additions and 57 deletions
+116 -45
View File
@@ -1,47 +1,82 @@
/**
* ComparisonChart Component
* Multi-school comparison chart using Chart.js
* 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 } from 'chart.js';
import { ChartOptions, ChartDataset, PointStyle } from 'chart.js';
import '@/lib/chartSetup';
import type { ComparisonData } from '@/lib/types';
import { CHART_COLORS, formatAcademicYear } from '@/lib/utils';
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;
}
export function ComparisonChart({ comparisonData, metric, metricLabel }: ComparisonChartProps) {
// Get all schools and their data
const schools = Object.entries(comparisonData);
// 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) {
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>;
}
// Get years from first school (assuming all schools have same years)
const years = schools[0][1].yearly_data.map((d) => d.year).sort((a, b) => a - b);
// 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);
// Create datasets for each school
const datasets = schools.map(([urn, data], index) => {
const schoolInfo = data.school_info;
const datasets: ChartDataset<'line'>[] = schools.map((school, index) => {
const data = comparisonData[String(school.urn)];
const color = CHART_COLORS[index % CHART_COLORS.length];
const dimmed = focusedUrn !== null && focusedUrn !== school.urn;
return {
label: schoolInfo.school_name,
label: school.school_name,
data: years.map((year) => {
const yearData = data.yearly_data.find((d) => d.year === 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;
}),
borderColor: color,
backgroundColor: color.replace('rgb', 'rgba').replace(')', ', 0.1)'),
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],
pointRadius: dimmed ? 2 : isMobile ? 3 : 4,
pointHoverRadius: isMobile ? 5 : 6,
tension: 0.3,
spanGaps: true,
};
@@ -52,9 +87,11 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
datasets,
};
// Determine if metric is a progress score or percentage
const isProgressScore = metric.includes('progress');
const isPercentage = metric.includes('pct') || metric.includes('rate');
const kind = metricKind(metric);
const yBounds = computeYBounds(
datasets.flatMap((ds) => ds.data as Array<number | null>),
kind,
);
const options: ChartOptions<'line'> = {
responsive: true,
@@ -65,6 +102,7 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
},
plugins: {
legend: {
display: !isMobile,
position: 'top' as const,
labels: {
usePointStyle: true,
@@ -74,26 +112,22 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
},
},
},
// No in-chart title: the section heading and metric selector above the
// chart already state the metric.
title: {
display: true,
text: `${metricLabel} - Comparison`,
font: {
size: 16,
weight: 'bold',
},
padding: {
bottom: 20,
},
display: false,
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12,
padding: isMobile ? 10 : 12,
titleFont: {
size: 14,
size: isMobile ? 12 : 14,
},
bodyFont: {
size: 13,
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 || '';
@@ -101,13 +135,7 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
label += ': ';
}
if (context.parsed.y !== null) {
if (isProgressScore) {
label += context.parsed.y.toFixed(1);
} else if (isPercentage) {
label += context.parsed.y.toFixed(1) + '%';
} else {
label += context.parsed.y.toFixed(1);
}
label += context.parsed.y.toFixed(1) + (kind === 'percentage' ? '%' : '');
} else {
label += 'N/A';
}
@@ -121,17 +149,18 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
type: 'linear' as const,
display: true,
title: {
display: true,
text: isPercentage ? 'Percentage (%)' : isProgressScore ? 'Progress Score' : 'Value',
display: !isMobile,
text: kind === 'percentage' ? 'Percentage (%)' : kind === 'progress' ? 'Progress Score' : 'Value',
font: {
size: 12,
weight: 'bold',
},
},
...(isPercentage && {
min: 0,
max: 100,
}),
...yBounds,
ticks: {
font: { size: isMobile ? 10 : 12 },
...(isMobile && { maxTicksLimit: 5 }),
},
grid: {
color: 'rgba(0, 0, 0, 0.05)',
},
@@ -141,16 +170,58 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
display: false,
},
title: {
display: true,
display: !isMobile,
text: 'Year',
font: {
size: 12,
weight: 'bold',
},
},
ticks: {
font: { size: isMobile ? 10 : 12 },
...(isMobile && { maxRotation: 0, autoSkip: true, maxTicksLimit: 4 }),
},
},
},
};
return <Line data={chartData} options={options} />;
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>
</div>
);
}