/** * 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 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; /** Ordered as displayed in the school cards, so colours match by index. */ schools: Array<{ urn: number; school_name: string }>; metric: string; metricLabel: string; } // 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(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
No data available
; } // 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); 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: 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; }), 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, }; }); const chartData = { labels: years.map(formatAcademicYear), datasets, }; const kind = metricKind(metric); const yBounds = computeYBounds( datasets.flatMap((ds) => ds.data as Array), 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' : 'Value', 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 (
{/* Mobile legend + focus control; a single series needs no legend. */} {schools.length > 1 && (
{schools.map((school, index) => ( ))}
)}
); }