initial commit
This commit is contained in:
831
frontend/app.js
Normal file
831
frontend/app.js
Normal file
@@ -0,0 +1,831 @@
|
||||
/**
|
||||
* School Performance Compass - Frontend Application
|
||||
* Interactive UK School Data Visualization
|
||||
*/
|
||||
|
||||
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 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 => `
|
||||
<div class="school-card" data-urn="${school.urn}">
|
||||
<h3 class="school-name">${escapeHtml(school.school_name)}</h3>
|
||||
<div class="school-meta">
|
||||
<span class="school-tag">${escapeHtml(school.local_authority || '')}</span>
|
||||
<span class="school-tag type">${escapeHtml(school.school_type || '')}</span>
|
||||
</div>
|
||||
<div class="school-address">${escapeHtml(school.address || '')}</div>
|
||||
<div class="school-stats">
|
||||
<div class="stat">
|
||||
<div class="stat-value">Primary</div>
|
||||
<div class="stat-label">Phase</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-value">KS2</div>
|
||||
<div class="stat-label">Data</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).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 `
|
||||
<div class="ranking-item" data-urn="${school.urn}">
|
||||
<div class="ranking-position ${index < 3 ? 'top-3' : ''}">${index + 1}</div>
|
||||
<div class="ranking-info">
|
||||
<div class="ranking-name">${escapeHtml(school.school_name)}</div>
|
||||
<div class="ranking-location">${escapeHtml(school.local_authority || '')}</div>
|
||||
</div>
|
||||
<div class="ranking-score">
|
||||
<div class="ranking-score-value">${displayValue}</div>
|
||||
<div class="ranking-score-label">${metricLabels[metric] || metric}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).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 = `
|
||||
<div class="empty-selection">
|
||||
<div class="empty-icon">
|
||||
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="6" y="10" width="36" height="28" rx="2"/>
|
||||
<path d="M6 18h36"/>
|
||||
<circle cx="14" cy="14" r="2" fill="currentColor"/>
|
||||
<circle cx="22" cy="14" r="2" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p>Search and add schools to compare</p>
|
||||
</div>
|
||||
`;
|
||||
elements.chartsSection.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
elements.selectedSchools.innerHTML = selectedSchools.map((school, index) => `
|
||||
<div class="selected-school-tag" style="border-left: 3px solid ${CHART_COLORS[index % CHART_COLORS.length]}">
|
||||
<span>${escapeHtml(school.school_name)}</span>
|
||||
<button class="remove" data-urn="${school.urn}" title="Remove">
|
||||
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 4L4 12M4 4l8 8"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`).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) {
|
||||
// Build header
|
||||
let headerHtml = '<th>School</th>';
|
||||
years.forEach(year => {
|
||||
headerHtml += `<th>${year}</th>`;
|
||||
});
|
||||
headerHtml += '<th>Change</th>';
|
||||
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 firstValue = yearlyMap[years[0]];
|
||||
const lastValue = yearlyMap[years[years.length - 1]];
|
||||
const change = firstValue && lastValue ? (lastValue - firstValue).toFixed(2) : 'N/A';
|
||||
const changeClass = parseFloat(change) >= 0 ? 'positive' : 'negative';
|
||||
const color = CHART_COLORS[index % CHART_COLORS.length];
|
||||
|
||||
bodyHtml += `<tr>`;
|
||||
bodyHtml += `<td><strong style="border-left: 3px solid ${color}; padding-left: 8px;">${escapeHtml(schoolData.school_info.school_name)}</strong></td>`;
|
||||
years.forEach(year => {
|
||||
const value = yearlyMap[year];
|
||||
bodyHtml += `<td>${value !== undefined ? formatMetricValue(value, metric) : '-'}</td>`;
|
||||
});
|
||||
bodyHtml += `<td class="${changeClass}">${change !== 'N/A' ? (parseFloat(change) >= 0 ? '+' : '') + change : change}</td>`;
|
||||
bodyHtml += `</tr>`;
|
||||
});
|
||||
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 = `
|
||||
<span class="school-tag">${escapeHtml(data.school_info.local_authority || '')}</span>
|
||||
<span class="school-tag type">${escapeHtml(data.school_info.school_type || '')}</span>
|
||||
<span class="school-tag">Primary</span>
|
||||
`;
|
||||
|
||||
// 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 = `
|
||||
<div class="modal-stats-section">
|
||||
<h4>KS2 Results (${latest.year})</h4>
|
||||
<div class="modal-stats-grid">
|
||||
<div class="modal-stat">
|
||||
<div class="modal-stat-value">${formatMetricValue(latest.rwm_expected_pct, 'rwm_expected_pct')}</div>
|
||||
<div class="modal-stat-label">RWM Expected</div>
|
||||
</div>
|
||||
<div class="modal-stat">
|
||||
<div class="modal-stat-value">${formatMetricValue(latest.rwm_high_pct, 'rwm_high_pct')}</div>
|
||||
<div class="modal-stat-label">RWM Higher</div>
|
||||
</div>
|
||||
<div class="modal-stat">
|
||||
<div class="modal-stat-value">${formatMetricValue(latest.gps_expected_pct, 'gps_expected_pct')}</div>
|
||||
<div class="modal-stat-label">GPS Expected</div>
|
||||
</div>
|
||||
<div class="modal-stat">
|
||||
<div class="modal-stat-value">${formatMetricValue(latest.science_expected_pct, 'science_expected_pct')}</div>
|
||||
<div class="modal-stat-label">Science Expected</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-stats-section">
|
||||
<h4>Progress Scores</h4>
|
||||
<div class="modal-stats-grid">
|
||||
<div class="modal-stat">
|
||||
<div class="modal-stat-value ${getProgressClass(latest.reading_progress)}">${formatMetricValue(latest.reading_progress, 'reading_progress')}</div>
|
||||
<div class="modal-stat-label">Reading</div>
|
||||
</div>
|
||||
<div class="modal-stat">
|
||||
<div class="modal-stat-value ${getProgressClass(latest.writing_progress)}">${formatMetricValue(latest.writing_progress, 'writing_progress')}</div>
|
||||
<div class="modal-stat-label">Writing</div>
|
||||
</div>
|
||||
<div class="modal-stat">
|
||||
<div class="modal-stat-value ${getProgressClass(latest.maths_progress)}">${formatMetricValue(latest.maths_progress, 'maths_progress')}</div>
|
||||
<div class="modal-stat-label">Maths</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-stats-section">
|
||||
<h4>School Context</h4>
|
||||
<div class="modal-stats-grid">
|
||||
<div class="modal-stat">
|
||||
<div class="modal-stat-value">${latest.total_pupils || '-'}</div>
|
||||
<div class="modal-stat-label">Total Pupils</div>
|
||||
</div>
|
||||
<div class="modal-stat">
|
||||
<div class="modal-stat-value">${formatMetricValue(latest.disadvantaged_pct, 'disadvantaged_pct')}</div>
|
||||
<div class="modal-stat-label">% Disadvantaged</div>
|
||||
</div>
|
||||
<div class="modal-stat">
|
||||
<div class="modal-stat-value">${formatMetricValue(latest.eal_pct, 'eal_pct')}</div>
|
||||
<div class="modal-stat-label">% EAL</div>
|
||||
</div>
|
||||
<div class="modal-stat">
|
||||
<div class="modal-stat-value">${formatMetricValue(latest.sen_support_pct, 'sen_support_pct')}</div>
|
||||
<div class="modal-stat-label">% SEN Support</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<div class="empty-state">
|
||||
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<circle cx="24" cy="24" r="20"/>
|
||||
<path d="M16 20h16M16 28h10"/>
|
||||
</svg>
|
||||
<p>${escapeHtml(message)}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
|
||||
const results = data.schools.filter(s => !selectedSchools.some(sel => sel.urn === s.urn));
|
||||
|
||||
if (results.length === 0) {
|
||||
elements.compareResults.innerHTML = '<div class="compare-result-item"><span class="name">No schools found</span></div>';
|
||||
} else {
|
||||
elements.compareResults.innerHTML = results.slice(0, 10).map(school => `
|
||||
<div class="compare-result-item" data-urn="${school.urn}" data-name="${escapeHtml(school.school_name)}">
|
||||
<div class="name">${escapeHtml(school.school_name)}</div>
|
||||
<div class="location">${escapeHtml(school.local_authority || '')}${school.postcode ? ' • ' + escapeHtml(school.postcode) : ''}</div>
|
||||
</div>
|
||||
`).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);
|
||||
elements.compareSearch.value = '';
|
||||
elements.compareResults.classList.remove('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
elements.compareResults.classList.add('active');
|
||||
}, 300);
|
||||
});
|
||||
|
||||
elements.compareSearch.addEventListener('blur', () => {
|
||||
setTimeout(() => elements.compareResults.classList.remove('active'), 200);
|
||||
});
|
||||
|
||||
elements.compareSearch.addEventListener('focus', () => {
|
||||
if (elements.compareSearch.value.trim().length >= 2) {
|
||||
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') {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user