2026-04-13 21:22:24 +01:00
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
import { useEffect, useRef } from 'react';
|
|
|
|
|
import styles from './SatsChart.module.css';
|
|
|
|
|
|
|
|
|
|
interface SubjectData {
|
|
|
|
|
name: string;
|
|
|
|
|
expectedPct: number | null;
|
|
|
|
|
exceedingPct: number | null;
|
|
|
|
|
nationalExpectedPct: number | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface SatsChartProps {
|
|
|
|
|
subjects: SubjectData[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const RULER_TICKS = [0, 25, 50, 75, 100];
|
|
|
|
|
const GRIDLINE_POSITIONS = [25, 50, 75];
|
|
|
|
|
|
|
|
|
|
function SubjectColumn({ subject }: { subject: SubjectData }) {
|
|
|
|
|
const expectedRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
const exceedingRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
|
|
|
|
const { name, expectedPct, exceedingPct, nationalExpectedPct } = subject;
|
|
|
|
|
|
|
|
|
|
// Animate bars on mount
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const bars = [expectedRef.current, exceedingRef.current];
|
|
|
|
|
bars.forEach((bar) => {
|
|
|
|
|
if (!bar) return;
|
|
|
|
|
const target = bar.dataset.width;
|
|
|
|
|
bar.style.width = '0%';
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
bar.style.width = `${target}%`;
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}, [expectedPct, exceedingPct]);
|
|
|
|
|
|
|
|
|
|
if (expectedPct == null && exceedingPct == null) return null;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className={styles.subjectChart}>
|
|
|
|
|
<div className={styles.subjectName}>{name}</div>
|
|
|
|
|
<div className={styles.chartArea}>
|
|
|
|
|
{/* Gridlines */}
|
|
|
|
|
<div className={styles.gridlines}>
|
|
|
|
|
{GRIDLINE_POSITIONS.map((pct) => (
|
|
|
|
|
<div key={pct} className={styles.gridline} style={{ left: `${pct}%` }} />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* National average marker */}
|
|
|
|
|
{nationalExpectedPct != null && (
|
|
|
|
|
<div className={styles.natLine} style={{ left: `${nationalExpectedPct}%` }}>
|
|
|
|
|
<div className={styles.natPill}>{nationalExpectedPct.toFixed(0)}%</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Bars */}
|
|
|
|
|
<div className={styles.barGroup}>
|
|
|
|
|
{expectedPct != null && (
|
|
|
|
|
<div className={styles.barRow}>
|
2026-04-14 08:40:38 +01:00
|
|
|
<div className={styles.barHeader}>
|
|
|
|
|
<span className={styles.barLabelSuffix}>Expected</span>
|
|
|
|
|
<span className={styles.barLabel}>{expectedPct.toFixed(0)}%</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className={styles.barTrack}>
|
|
|
|
|
<div
|
|
|
|
|
ref={expectedRef}
|
|
|
|
|
className={`${styles.bar} ${styles.barExpected}`}
|
|
|
|
|
data-width={expectedPct}
|
|
|
|
|
/>
|
2026-04-13 21:22:24 +01:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{exceedingPct != null && (
|
|
|
|
|
<div className={styles.barRow}>
|
2026-04-14 08:40:38 +01:00
|
|
|
<div className={styles.barHeader}>
|
|
|
|
|
<span className={styles.barLabelSuffix}>Exceeding</span>
|
|
|
|
|
<span className={styles.barLabel}>{exceedingPct.toFixed(0)}%</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className={styles.barTrack}>
|
|
|
|
|
<div
|
|
|
|
|
ref={exceedingRef}
|
|
|
|
|
className={`${styles.bar} ${styles.barExceeding}`}
|
|
|
|
|
data-width={exceedingPct}
|
|
|
|
|
/>
|
2026-04-13 21:22:24 +01:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Ruler */}
|
|
|
|
|
<div className={styles.ruler}>
|
|
|
|
|
{RULER_TICKS.map((pct, i) => (
|
|
|
|
|
<span key={pct}>
|
|
|
|
|
<div className={styles.rulerTick} style={{ left: `${pct}%` }} />
|
|
|
|
|
<div
|
|
|
|
|
className={`${styles.rulerLabel} ${i === 0 ? styles.rulerLabelFirst : ''} ${i === RULER_TICKS.length - 1 ? styles.rulerLabelLast : ''}`}
|
|
|
|
|
style={{ left: `${pct}%` }}
|
|
|
|
|
>
|
|
|
|
|
{pct}%
|
|
|
|
|
</div>
|
|
|
|
|
</span>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function SatsChart({ subjects }: SatsChartProps) {
|
|
|
|
|
const visibleSubjects = subjects.filter(
|
|
|
|
|
(s) => s.expectedPct != null || s.exceedingPct != null
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (visibleSubjects.length === 0) return null;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
<div className={styles.container}>
|
|
|
|
|
{visibleSubjects.map((subject) => (
|
|
|
|
|
<SubjectColumn key={subject.name} subject={subject} />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<div className={styles.legend}>
|
|
|
|
|
<div className={styles.legendItem}>
|
|
|
|
|
<div className={styles.legendSwatch} style={{ background: 'var(--accent-teal-light, #3a9e9e)' }} />
|
|
|
|
|
Expected standard
|
|
|
|
|
</div>
|
|
|
|
|
<div className={styles.legendItem}>
|
|
|
|
|
<div className={styles.legendSwatch} style={{ background: 'var(--accent-teal, #2d7d7d)' }} />
|
|
|
|
|
Exceeding / high score
|
|
|
|
|
</div>
|
|
|
|
|
<div className={styles.legendItem}>
|
|
|
|
|
<div className={styles.legendSwatch} style={{ background: 'var(--accent-coral, #e07256)', borderRadius: '50%' }} />
|
|
|
|
|
National average
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|