/**
* SchoolCompare.co.uk - Frontend Application
* Interactive UK Primary School Performance Comparison
*/
const API_BASE = '';
// State
let allSchools = [];
let selectedSchools = [];
let comparisonChart = null;
let schoolDetailChart = null;
let currentSchoolData = null;
// Chart colors
const CHART_COLORS = [
'#e07256', // coral
'#2d7d7d', // teal
'#c9a227', // gold
'#7b68a6', // purple
'#3498db', // blue
'#27ae60', // green
'#e74c3c', // red
'#9b59b6', // violet
];
// DOM Elements
const elements = {
schoolSearch: document.getElementById('school-search'),
localAuthorityFilter: document.getElementById('local-authority-filter'),
typeFilter: document.getElementById('type-filter'),
schoolsGrid: document.getElementById('schools-grid'),
compareSearch: document.getElementById('compare-search'),
compareResults: document.getElementById('compare-results'),
selectedSchools: document.getElementById('selected-schools'),
chartsSection: document.getElementById('charts-section'),
metricSelect: document.getElementById('metric-select'),
comparisonChart: document.getElementById('comparison-chart'),
comparisonTable: document.getElementById('comparison-table'),
tableHeader: document.getElementById('table-header'),
tableBody: document.getElementById('table-body'),
rankingMetric: document.getElementById('ranking-metric'),
rankingYear: document.getElementById('ranking-year'),
rankingsList: document.getElementById('rankings-list'),
modal: document.getElementById('school-modal'),
modalClose: document.getElementById('modal-close'),
modalSchoolName: document.getElementById('modal-school-name'),
modalMeta: document.getElementById('modal-meta'),
modalStats: document.getElementById('modal-stats'),
schoolDetailChart: document.getElementById('school-detail-chart'),
addToCompare: document.getElementById('add-to-compare'),
};
// Initialize
document.addEventListener('DOMContentLoaded', init);
async function init() {
await loadFilters();
await loadSchools();
await loadRankingYears();
await loadRankings();
setupEventListeners();
}
// API Functions
async function fetchAPI(endpoint) {
try {
const response = await fetch(`${API_BASE}${endpoint}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
console.error(`API Error (${endpoint}):`, error);
return null;
}
}
async function loadFilters() {
const data = await fetchAPI('/api/filters');
if (!data) return;
// Populate local authority filter
data.local_authorities.forEach(la => {
const option = document.createElement('option');
option.value = la;
option.textContent = la;
elements.localAuthorityFilter.appendChild(option);
});
// Populate school type filter
data.school_types.forEach(type => {
const option = document.createElement('option');
option.value = type;
option.textContent = type;
elements.typeFilter.appendChild(option);
});
}
async function loadSchools() {
const params = new URLSearchParams();
const search = elements.schoolSearch.value.trim();
if (search) params.append('search', search);
const localAuthority = elements.localAuthorityFilter.value;
if (localAuthority) params.append('local_authority', localAuthority);
const type = elements.typeFilter.value;
if (type) params.append('school_type', type);
const queryString = params.toString();
const endpoint = `/api/schools${queryString ? '?' + queryString : ''}`;
const data = await fetchAPI(endpoint);
if (!data) {
showEmptyState(elements.schoolsGrid, 'Unable to load schools');
return;
}
allSchools = data.schools;
renderSchools(allSchools);
}
async function loadSchoolDetails(urn) {
const data = await fetchAPI(`/api/schools/${urn}`);
return data;
}
async function loadComparison() {
if (selectedSchools.length === 0) return null;
const urns = selectedSchools.map(s => s.urn).join(',');
const data = await fetchAPI(`/api/compare?urns=${urns}`);
return data;
}
async function loadRankingYears() {
const data = await fetchAPI('/api/filters');
if (!data) return;
elements.rankingYear.innerHTML = '';
data.years.sort((a, b) => b - a).forEach(year => {
const option = document.createElement('option');
option.value = year;
option.textContent = `${year}`;
elements.rankingYear.appendChild(option);
});
}
async function loadRankings() {
const metric = elements.rankingMetric.value;
const year = elements.rankingYear.value;
let endpoint = `/api/rankings?metric=${metric}&limit=20`;
if (year) endpoint += `&year=${year}`;
const data = await fetchAPI(endpoint);
if (!data) {
showEmptyState(elements.rankingsList, 'Unable to load rankings');
return;
}
renderRankings(data.rankings, metric);
}
// Render Functions
function renderSchools(schools) {
if (schools.length === 0) {
showEmptyState(elements.schoolsGrid, 'No primary schools found matching your criteria');
return;
}
elements.schoolsGrid.innerHTML = schools.map(school => `
${escapeHtml(school.school_name)}
${escapeHtml(school.local_authority || '')}
${escapeHtml(school.school_type || '')}
${escapeHtml(school.address || '')}
`).join('');
// Add click handlers
elements.schoolsGrid.querySelectorAll('.school-card').forEach(card => {
card.addEventListener('click', () => {
const urn = parseInt(card.dataset.urn);
openSchoolModal(urn);
});
});
}
function renderRankings(rankings, metric) {
if (rankings.length === 0) {
showEmptyState(elements.rankingsList, 'No ranking data available for this year/metric');
return;
}
const metricLabels = {
// Expected
rwm_expected_pct: 'RWM %',
reading_expected_pct: 'Reading %',
writing_expected_pct: 'Writing %',
maths_expected_pct: 'Maths %',
gps_expected_pct: 'GPS %',
science_expected_pct: 'Science %',
// Higher
rwm_high_pct: 'RWM Higher %',
reading_high_pct: 'Reading Higher %',
writing_high_pct: 'Writing Higher %',
maths_high_pct: 'Maths Higher %',
gps_high_pct: 'GPS Higher %',
// Progress
reading_progress: 'Reading Progress',
writing_progress: 'Writing Progress',
maths_progress: 'Maths Progress',
// Averages
reading_avg_score: 'Reading Avg',
maths_avg_score: 'Maths Avg',
gps_avg_score: 'GPS Avg',
// Gender
rwm_expected_boys_pct: 'Boys RWM %',
rwm_expected_girls_pct: 'Girls RWM %',
rwm_high_boys_pct: 'Boys Higher %',
rwm_high_girls_pct: 'Girls Higher %',
// Equity
rwm_expected_disadvantaged_pct: 'Disadvantaged %',
rwm_expected_non_disadvantaged_pct: 'Non-Disadv %',
disadvantaged_gap: 'Disadv Gap',
// Context
disadvantaged_pct: '% Disadvantaged',
eal_pct: '% EAL',
sen_support_pct: '% SEN',
stability_pct: '% Stable',
// 3-Year
rwm_expected_3yr_pct: 'RWM 3yr %',
reading_avg_3yr: 'Reading 3yr',
maths_avg_3yr: 'Maths 3yr',
};
elements.rankingsList.innerHTML = rankings.map((school, index) => {
const value = school[metric];
if (value === null || value === undefined) return '';
const isProgress = metric.includes('progress');
const isScore = metric.includes('_avg_');
let displayValue;
if (isProgress) {
displayValue = (value >= 0 ? '+' : '') + value.toFixed(1);
} else if (isScore) {
displayValue = value.toFixed(0);
} else {
displayValue = value.toFixed(0) + '%';
}
return `
${index + 1}
${escapeHtml(school.school_name)}
${escapeHtml(school.local_authority || '')}
${displayValue}
${metricLabels[metric] || metric}
`;
}).filter(Boolean).join('');
// Add click handlers
elements.rankingsList.querySelectorAll('.ranking-item').forEach(item => {
item.addEventListener('click', () => {
const urn = parseInt(item.dataset.urn);
openSchoolModal(urn);
});
});
}
function renderSelectedSchools() {
if (selectedSchools.length === 0) {
elements.selectedSchools.innerHTML = `
Search and add schools to compare
`;
elements.chartsSection.style.display = 'none';
return;
}
elements.selectedSchools.innerHTML = selectedSchools.map((school, index) => `
${escapeHtml(school.school_name)}
`).join('');
// Add remove handlers
elements.selectedSchools.querySelectorAll('.remove').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const urn = parseInt(btn.dataset.urn);
removeFromComparison(urn);
});
});
elements.chartsSection.style.display = 'block';
updateComparisonChart();
}
async function updateComparisonChart() {
if (selectedSchools.length === 0) return;
const data = await loadComparison();
if (!data) return;
const metric = elements.metricSelect.value;
const metricLabels = {
// Expected Standard
rwm_expected_pct: 'Reading, Writing & Maths Combined (%)',
reading_expected_pct: 'Reading Expected Standard (%)',
writing_expected_pct: 'Writing Expected Standard (%)',
maths_expected_pct: 'Maths Expected Standard (%)',
gps_expected_pct: 'GPS Expected Standard (%)',
science_expected_pct: 'Science Expected Standard (%)',
// Higher Standard
rwm_high_pct: 'RWM Combined Higher Standard (%)',
reading_high_pct: 'Reading Higher Standard (%)',
writing_high_pct: 'Writing Greater Depth (%)',
maths_high_pct: 'Maths Higher Standard (%)',
gps_high_pct: 'GPS Higher Standard (%)',
// Progress
reading_progress: 'Reading Progress Score',
writing_progress: 'Writing Progress Score',
maths_progress: 'Maths Progress Score',
// Averages
reading_avg_score: 'Reading Average Scaled Score',
maths_avg_score: 'Maths Average Scaled Score',
gps_avg_score: 'GPS Average Scaled Score',
// Gender
rwm_expected_boys_pct: 'RWM Expected % (Boys)',
rwm_expected_girls_pct: 'RWM Expected % (Girls)',
rwm_high_boys_pct: 'RWM Higher % (Boys)',
rwm_high_girls_pct: 'RWM Higher % (Girls)',
// Equity
rwm_expected_disadvantaged_pct: 'RWM Expected % (Disadvantaged)',
rwm_expected_non_disadvantaged_pct: 'RWM Expected % (Non-Disadvantaged)',
disadvantaged_gap: 'Disadvantaged Gap vs National',
// Context
disadvantaged_pct: '% Disadvantaged Pupils',
eal_pct: '% EAL Pupils',
sen_support_pct: '% SEN Support',
stability_pct: '% Pupil Stability',
// 3-Year
rwm_expected_3yr_pct: 'RWM Expected % (3-Year Avg)',
reading_avg_3yr: 'Reading Score (3-Year Avg)',
maths_avg_3yr: 'Maths Score (3-Year Avg)',
};
// Prepare chart data - iterate in same order as selectedSchools for color consistency
const datasets = [];
const allYears = new Set();
selectedSchools.forEach((school, index) => {
const schoolData = data.comparison[school.urn];
if (!schoolData) return;
const yearlyData = schoolData.yearly_data;
yearlyData.forEach(d => allYears.add(d.year));
const sortedData = yearlyData.sort((a, b) => a.year - b.year);
datasets.push({
label: schoolData.school_info.school_name,
data: sortedData.map(d => ({ x: d.year, y: d[metric] })),
borderColor: CHART_COLORS[index % CHART_COLORS.length],
backgroundColor: CHART_COLORS[index % CHART_COLORS.length] + '20',
borderWidth: 3,
pointRadius: 5,
pointHoverRadius: 7,
tension: 0.3,
fill: false,
});
});
const years = Array.from(allYears).sort();
// Destroy existing chart
if (comparisonChart) {
comparisonChart.destroy();
}
// Create new chart
const ctx = elements.comparisonChart.getContext('2d');
comparisonChart = new Chart(ctx, {
type: 'line',
data: {
labels: years,
datasets: datasets,
},
options: {
responsive: true,
maintainAspectRatio: true,
aspectRatio: 2,
plugins: {
legend: {
position: 'bottom',
labels: {
font: { family: "'DM Sans', sans-serif", size: 12 },
padding: 20,
usePointStyle: true,
},
},
title: {
display: true,
text: metricLabels[metric],
font: { family: "'Playfair Display', serif", size: 18, weight: 600 },
padding: { bottom: 20 },
},
tooltip: {
backgroundColor: '#1a1612',
titleFont: { family: "'DM Sans', sans-serif" },
bodyFont: { family: "'DM Sans', sans-serif" },
padding: 12,
cornerRadius: 8,
},
},
scales: {
x: {
title: {
display: true,
text: 'Academic Year',
font: { family: "'DM Sans', sans-serif", weight: 500 },
},
grid: { display: false },
},
y: {
title: {
display: true,
text: metricLabels[metric],
font: { family: "'DM Sans', sans-serif", weight: 500 },
},
grid: { color: '#e5dfd5' },
},
},
interaction: {
intersect: false,
mode: 'index',
},
},
});
// Update comparison table
updateComparisonTable(data.comparison, metric, years);
}
function updateComparisonTable(comparison, metric, years) {
const lastYear = years[years.length - 1];
const prevYear = years.length > 1 ? years[years.length - 2] : null;
// Build header with explicit year ranges
let headerHtml = 'School | ';
years.forEach(year => {
headerHtml += `${year} | `;
});
if (prevYear) {
headerHtml += `Δ 1yr | `;
}
if (years.length > 2) {
headerHtml += `Variability | `;
}
elements.tableHeader.innerHTML = headerHtml;
// Build body - iterate in same order as selectedSchools for color consistency
let bodyHtml = '';
selectedSchools.forEach((school, index) => {
const schoolData = comparison[school.urn];
if (!schoolData) return;
const yearlyMap = {};
schoolData.yearly_data.forEach(d => {
yearlyMap[d.year] = d[metric];
});
const lastValue = yearlyMap[lastYear];
const prevValue = prevYear ? yearlyMap[prevYear] : null;
// Calculate 1-year change
const oneYearChange = prevValue != null && lastValue != null ? (lastValue - prevValue) : null;
const oneYearChangeStr = oneYearChange !== null ? oneYearChange.toFixed(1) : 'N/A';
const oneYearClass = oneYearChange !== null ? (oneYearChange >= 0 ? 'positive' : 'negative') : '';
// Calculate variability (standard deviation) - exclude null/0 values
const values = years.map(y => yearlyMap[y]).filter(v => v != null && v !== 0);
let variabilityStr = 'N/A';
if (values.length >= 2) {
const mean = values.reduce((a, b) => a + b, 0) / values.length;
const squaredDiffs = values.map(v => Math.pow(v - mean, 2));
const variance = squaredDiffs.reduce((a, b) => a + b, 0) / values.length;
const stdDev = Math.sqrt(variance);
variabilityStr = '±' + stdDev.toFixed(1);
}
const color = CHART_COLORS[index % CHART_COLORS.length];
bodyHtml += ``;
bodyHtml += `| ${escapeHtml(schoolData.school_info.school_name)} | `;
years.forEach(year => {
const value = yearlyMap[year];
bodyHtml += `${value != null ? formatMetricValue(value, metric) : '-'} | `;
});
if (prevYear) {
bodyHtml += `${oneYearChangeStr !== 'N/A' ? (oneYearChange >= 0 ? '+' : '') + oneYearChangeStr : oneYearChangeStr} | `;
}
if (years.length > 2) {
bodyHtml += `${variabilityStr} | `;
}
bodyHtml += `
`;
});
elements.tableBody.innerHTML = bodyHtml;
}
function formatMetricValue(value, metric) {
if (value === null || value === undefined) return '-';
if (metric.includes('progress')) {
return (value >= 0 ? '+' : '') + value.toFixed(1);
}
if (metric.includes('pct')) {
return value.toFixed(0) + '%';
}
return value.toFixed(1);
}
async function openSchoolModal(urn) {
const data = await loadSchoolDetails(urn);
if (!data) return;
currentSchoolData = data;
elements.modalSchoolName.textContent = data.school_info.school_name;
elements.modalMeta.innerHTML = `
${escapeHtml(data.school_info.local_authority || '')}
${escapeHtml(data.school_info.school_type || '')}
Primary
`;
// Get latest year data with actual results (skip 2021 - no SATs)
const sortedData = data.yearly_data.sort((a, b) => b.year - a.year);
const latest = sortedData.find(d => d.rwm_expected_pct !== null) || sortedData[0];
elements.modalStats.innerHTML = `
KS2 Results (${latest.year})
${formatMetricValue(latest.rwm_expected_pct, 'rwm_expected_pct')}
RWM Expected
${formatMetricValue(latest.rwm_high_pct, 'rwm_high_pct')}
RWM Higher
${formatMetricValue(latest.gps_expected_pct, 'gps_expected_pct')}
GPS Expected
${formatMetricValue(latest.science_expected_pct, 'science_expected_pct')}
Science Expected
Progress Scores
${formatMetricValue(latest.reading_progress, 'reading_progress')}
Reading
${formatMetricValue(latest.writing_progress, 'writing_progress')}
Writing
${formatMetricValue(latest.maths_progress, 'maths_progress')}
Maths
School Context
${latest.total_pupils || '-'}
Total Pupils
${formatMetricValue(latest.disadvantaged_pct, 'disadvantaged_pct')}
% Disadvantaged
${formatMetricValue(latest.eal_pct, 'eal_pct')}
% EAL
${formatMetricValue(latest.sen_support_pct, 'sen_support_pct')}
% SEN Support
`;
function getProgressClass(value) {
if (value === null || value === undefined) return '';
return value >= 0 ? 'positive' : 'negative';
}
// Create chart - filter out years with no data (2021)
if (schoolDetailChart) {
schoolDetailChart.destroy();
}
const validData = sortedData.filter(d => d.rwm_expected_pct !== null).reverse();
const years = validData.map(d => d.year);
const ctx = elements.schoolDetailChart.getContext('2d');
schoolDetailChart = new Chart(ctx, {
type: 'bar',
data: {
labels: years,
datasets: [
{
label: 'Reading %',
data: validData.map(d => d.reading_expected_pct),
backgroundColor: '#2d7d7d',
borderRadius: 4,
},
{
label: 'Writing %',
data: validData.map(d => d.writing_expected_pct),
backgroundColor: '#c9a227',
borderRadius: 4,
},
{
label: 'Maths %',
data: validData.map(d => d.maths_expected_pct),
backgroundColor: '#e07256',
borderRadius: 4,
},
],
},
options: {
responsive: true,
maintainAspectRatio: true,
aspectRatio: 2,
plugins: {
legend: {
position: 'bottom',
labels: {
font: { family: "'DM Sans', sans-serif" },
usePointStyle: true,
},
},
title: {
display: true,
text: 'KS2 Attainment Over Time (% meeting expected standard)',
font: { family: "'Playfair Display', serif", size: 16, weight: 600 },
},
},
scales: {
y: {
beginAtZero: true,
max: 100,
grid: { color: '#e5dfd5' },
},
x: {
grid: { display: false },
},
},
},
});
// Update add to compare button
const isSelected = selectedSchools.some(s => s.urn === data.school_info.urn);
elements.addToCompare.textContent = isSelected ? 'Remove from Compare' : 'Add to Compare';
elements.addToCompare.dataset.urn = data.school_info.urn;
elements.modal.classList.add('active');
document.body.style.overflow = 'hidden';
}
function closeModal() {
elements.modal.classList.remove('active');
document.body.style.overflow = '';
currentSchoolData = null;
}
function addToComparison(school) {
if (selectedSchools.some(s => s.urn === school.urn)) return;
if (selectedSchools.length >= 5) {
alert('Maximum 5 schools can be compared at once');
return;
}
selectedSchools.push(school);
renderSelectedSchools();
}
function removeFromComparison(urn) {
selectedSchools = selectedSchools.filter(s => s.urn !== urn);
renderSelectedSchools();
}
function showEmptyState(container, message) {
container.innerHTML = `
`;
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Event Listeners
function setupEventListeners() {
// Navigation
document.querySelectorAll('.nav-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const view = link.dataset.view;
document.querySelectorAll('.nav-link').forEach(l => l.classList.remove('active'));
link.classList.add('active');
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
document.getElementById(`${view}-view`).classList.add('active');
});
});
// Search and filters
let searchTimeout;
elements.schoolSearch.addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(loadSchools, 300);
});
elements.localAuthorityFilter.addEventListener('change', loadSchools);
elements.typeFilter.addEventListener('change', loadSchools);
// Compare search
let compareSearchTimeout;
let lastCompareSearchData = null;
function renderCompareResults(data) {
if (!data) return;
lastCompareSearchData = data;
const results = data.schools.filter(s => !selectedSchools.some(sel => sel.urn === s.urn));
const headerHtml = `
`;
if (results.length === 0) {
elements.compareResults.innerHTML = headerHtml + 'No more schools to add
';
} else {
elements.compareResults.innerHTML = headerHtml + results.slice(0, 10).map(school => `
${escapeHtml(school.school_name)}
${escapeHtml(school.local_authority || '')}${school.postcode ? ' • ' + escapeHtml(school.postcode) : ''}
`).join('');
elements.compareResults.querySelectorAll('.compare-result-item').forEach(item => {
item.addEventListener('click', () => {
const urn = parseInt(item.dataset.urn);
const school = data.schools.find(s => s.urn === urn);
if (school) {
addToComparison(school);
// Re-render results without closing (filter out newly added school)
renderCompareResults(data);
}
});
});
}
// Add close button handler
const closeBtn = elements.compareResults.querySelector('.compare-results-close');
if (closeBtn) {
closeBtn.addEventListener('click', () => {
elements.compareResults.classList.remove('active');
elements.compareSearch.value = '';
});
}
}
elements.compareSearch.addEventListener('input', async () => {
clearTimeout(compareSearchTimeout);
const query = elements.compareSearch.value.trim();
if (query.length < 2) {
elements.compareResults.classList.remove('active');
return;
}
compareSearchTimeout = setTimeout(async () => {
const data = await fetchAPI(`/api/schools?search=${encodeURIComponent(query)}`);
if (!data) return;
renderCompareResults(data);
elements.compareResults.classList.add('active');
}, 300);
});
elements.compareSearch.addEventListener('focus', () => {
if (elements.compareSearch.value.trim().length >= 2 && lastCompareSearchData) {
renderCompareResults(lastCompareSearchData);
elements.compareResults.classList.add('active');
}
});
// Metric selector
elements.metricSelect.addEventListener('change', updateComparisonChart);
// Rankings
elements.rankingMetric.addEventListener('change', loadRankings);
elements.rankingYear.addEventListener('change', loadRankings);
// Modal
elements.modalClose.addEventListener('click', closeModal);
elements.modal.querySelector('.modal-backdrop').addEventListener('click', closeModal);
elements.addToCompare.addEventListener('click', () => {
if (!currentSchoolData) return;
const urn = currentSchoolData.school_info.urn;
const isSelected = selectedSchools.some(s => s.urn === urn);
if (isSelected) {
removeFromComparison(urn);
elements.addToCompare.textContent = 'Add to Compare';
} else {
addToComparison(currentSchoolData.school_info);
elements.addToCompare.textContent = 'Remove from Compare';
}
});
// Keyboard
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
// Close compare results if open
if (elements.compareResults.classList.contains('active')) {
elements.compareResults.classList.remove('active');
elements.compareSearch.value = '';
return;
}
// Close modal if open
closeModal();
}
});
}