initial commit

This commit is contained in:
Tudor Sitaru
2026-01-06 13:52:00 +00:00
commit c65eb1a00f
37 changed files with 402537 additions and 0 deletions

831
frontend/app.js Normal file
View 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();
}
});
}

261
frontend/index.html Normal file
View File

@@ -0,0 +1,261 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Primary School Compass | Wandsworth & Merton</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=Playfair+Display:wght@600;700&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<div class="noise-overlay"></div>
<header class="header">
<div class="header-content">
<div class="logo">
<div class="logo-icon">
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="20" cy="20" r="18" stroke="currentColor" stroke-width="2"/>
<path d="M20 8L20 32M12 14L28 14M10 20L30 20M12 26L28 26" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<circle cx="20" cy="20" r="4" fill="currentColor"/>
</svg>
</div>
<div class="logo-text">
<span class="logo-title">Primary School Compass</span>
<span class="logo-subtitle">Wandsworth & Merton</span>
</div>
</div>
<nav class="nav">
<a href="#" class="nav-link active" data-view="dashboard">Dashboard</a>
<a href="#" class="nav-link" data-view="compare">Compare</a>
<a href="#" class="nav-link" data-view="rankings">Rankings</a>
</nav>
</div>
</header>
<main class="main">
<!-- Dashboard View -->
<section id="dashboard-view" class="view active">
<div class="hero">
<h1 class="hero-title">Primary Schools in Wandsworth & Merton</h1>
<p class="hero-subtitle">Compare KS2 performance data from the last 5 years across local primary schools</p>
</div>
<div class="search-section">
<div class="search-container">
<input type="text" id="school-search" class="search-input" placeholder="Search primary schools by name...">
<div class="search-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/>
<path d="M21 21l-4.35-4.35"/>
</svg>
</div>
</div>
<div class="filter-row">
<select id="local-authority-filter" class="filter-select">
<option value="">All Areas</option>
<option value="Wandsworth">Wandsworth</option>
<option value="Merton">Merton</option>
</select>
<select id="type-filter" class="filter-select">
<option value="">All School Types</option>
</select>
</div>
</div>
<div class="schools-grid" id="schools-grid">
<!-- School cards populated by JS -->
</div>
</section>
<!-- Compare View -->
<section id="compare-view" class="view">
<div class="compare-header">
<h2 class="section-title">Compare Primary Schools</h2>
<p class="section-subtitle">Select schools to compare their KS2 performance over time</p>
</div>
<div class="selected-schools" id="selected-schools">
<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>
</div>
<div class="compare-search-section">
<input type="text" id="compare-search" class="search-input" placeholder="Add a school to compare...">
<div id="compare-results" class="compare-results"></div>
</div>
<div class="charts-section" id="charts-section" style="display: none;">
<div class="metric-selector">
<label>Select KS2 Metric:</label>
<select id="metric-select" class="filter-select">
<optgroup label="Expected Standard">
<option value="rwm_expected_pct">Reading, Writing & Maths Combined %</option>
<option value="reading_expected_pct">Reading Expected %</option>
<option value="writing_expected_pct">Writing Expected %</option>
<option value="maths_expected_pct">Maths Expected %</option>
<option value="gps_expected_pct">GPS Expected %</option>
<option value="science_expected_pct">Science Expected %</option>
</optgroup>
<optgroup label="Higher Standard">
<option value="rwm_high_pct">RWM Combined Higher %</option>
<option value="reading_high_pct">Reading Higher %</option>
<option value="writing_high_pct">Writing Higher %</option>
<option value="maths_high_pct">Maths Higher %</option>
<option value="gps_high_pct">GPS Higher %</option>
</optgroup>
<optgroup label="Progress Scores">
<option value="reading_progress">Reading Progress</option>
<option value="writing_progress">Writing Progress</option>
<option value="maths_progress">Maths Progress</option>
</optgroup>
<optgroup label="Average Scores">
<option value="reading_avg_score">Reading Avg Score</option>
<option value="maths_avg_score">Maths Avg Score</option>
<option value="gps_avg_score">GPS Avg Score</option>
</optgroup>
<optgroup label="Gender Performance">
<option value="rwm_expected_boys_pct">RWM Expected % (Boys)</option>
<option value="rwm_expected_girls_pct">RWM Expected % (Girls)</option>
<option value="rwm_high_boys_pct">RWM Higher % (Boys)</option>
<option value="rwm_high_girls_pct">RWM Higher % (Girls)</option>
</optgroup>
<optgroup label="Equity (Disadvantaged)">
<option value="rwm_expected_disadvantaged_pct">RWM Expected % (Disadvantaged)</option>
<option value="rwm_expected_non_disadvantaged_pct">RWM Expected % (Non-Disadvantaged)</option>
<option value="disadvantaged_gap">Disadvantaged Gap vs National</option>
</optgroup>
<optgroup label="School Context">
<option value="disadvantaged_pct">% Disadvantaged Pupils</option>
<option value="eal_pct">% EAL Pupils</option>
<option value="sen_support_pct">% SEN Support</option>
<option value="stability_pct">% Pupil Stability</option>
</optgroup>
<optgroup label="3-Year Trends">
<option value="rwm_expected_3yr_pct">RWM Expected % (3-Year Avg)</option>
<option value="reading_avg_3yr">Reading Score (3-Year Avg)</option>
<option value="maths_avg_3yr">Maths Score (3-Year Avg)</option>
</optgroup>
</select>
</div>
<div class="chart-container">
<canvas id="comparison-chart"></canvas>
</div>
<div class="data-table-container">
<table class="data-table" id="comparison-table">
<thead>
<tr id="table-header"></tr>
</thead>
<tbody id="table-body"></tbody>
</table>
</div>
</div>
</section>
<!-- Rankings View -->
<section id="rankings-view" class="view">
<div class="rankings-header">
<h2 class="section-title">Primary School Rankings</h2>
<p class="section-subtitle">Top performing schools in Wandsworth & Merton by KS2 metric</p>
</div>
<div class="rankings-controls">
<select id="ranking-metric" class="filter-select">
<optgroup label="Expected Standard">
<option value="rwm_expected_pct">Reading, Writing & Maths Combined %</option>
<option value="reading_expected_pct">Reading Expected %</option>
<option value="writing_expected_pct">Writing Expected %</option>
<option value="maths_expected_pct">Maths Expected %</option>
<option value="gps_expected_pct">GPS Expected %</option>
<option value="science_expected_pct">Science Expected %</option>
</optgroup>
<optgroup label="Higher Standard">
<option value="rwm_high_pct">RWM Combined Higher %</option>
<option value="reading_high_pct">Reading Higher %</option>
<option value="writing_high_pct">Writing Higher %</option>
<option value="maths_high_pct">Maths Higher %</option>
<option value="gps_high_pct">GPS Higher %</option>
</optgroup>
<optgroup label="Progress Scores">
<option value="reading_progress">Reading Progress</option>
<option value="writing_progress">Writing Progress</option>
<option value="maths_progress">Maths Progress</option>
</optgroup>
<optgroup label="Average Scores">
<option value="reading_avg_score">Reading Avg Score</option>
<option value="maths_avg_score">Maths Avg Score</option>
<option value="gps_avg_score">GPS Avg Score</option>
</optgroup>
<optgroup label="Gender Performance">
<option value="rwm_expected_boys_pct">RWM Expected % (Boys)</option>
<option value="rwm_expected_girls_pct">RWM Expected % (Girls)</option>
<option value="rwm_high_boys_pct">RWM Higher % (Boys)</option>
<option value="rwm_high_girls_pct">RWM Higher % (Girls)</option>
</optgroup>
<optgroup label="Equity (Disadvantaged)">
<option value="rwm_expected_disadvantaged_pct">RWM Expected % (Disadvantaged)</option>
<option value="rwm_expected_non_disadvantaged_pct">RWM Expected % (Non-Disadvantaged)</option>
</optgroup>
<optgroup label="3-Year Trends">
<option value="rwm_expected_3yr_pct">RWM Expected % (3-Year Avg)</option>
</optgroup>
</select>
<select id="ranking-year" class="filter-select">
<!-- Populated by JS -->
</select>
</div>
<div class="rankings-list" id="rankings-list">
<!-- Rankings populated by JS -->
</div>
</section>
</main>
<!-- School Detail Modal -->
<div class="modal" id="school-modal">
<div class="modal-backdrop"></div>
<div class="modal-content">
<button class="modal-close" id="modal-close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
<div class="modal-header">
<h2 id="modal-school-name"></h2>
<div class="modal-meta" id="modal-meta"></div>
</div>
<div class="modal-body">
<div class="modal-chart-container">
<canvas id="school-detail-chart"></canvas>
</div>
<div class="modal-stats" id="modal-stats"></div>
</div>
<div class="modal-footer">
<button class="btn btn-primary" id="add-to-compare">Add to Compare</button>
</div>
</div>
</div>
<footer class="footer">
<p>Data source: <a href="https://www.compare-school-performance.service.gov.uk/download-data" target="_blank">UK Government - Compare School Performance</a></p>
<p class="footer-note">Primary school (KS2) data for Wandsworth and Merton. Data from 2019-2020, 2020-2021, 2021-2022 unavailable due to COVID-19 disruption.</p>
</footer>
<script src="/static/app.js"></script>
</body>
</html>

931
frontend/styles.css Normal file
View File

@@ -0,0 +1,931 @@
/*
* School Performance Compass
* A warm, editorial design inspired by quality publications
*/
:root {
/* Warm, sophisticated palette */
--bg-primary: #faf7f2;
--bg-secondary: #f3ede4;
--bg-card: #ffffff;
--bg-accent: #1a1612;
--text-primary: #1a1612;
--text-secondary: #5c564d;
--text-muted: #8a847a;
--text-inverse: #faf7f2;
--accent-coral: #e07256;
--accent-coral-dark: #c45a3f;
--accent-teal: #2d7d7d;
--accent-teal-light: #3a9e9e;
--accent-gold: #c9a227;
--accent-navy: #2c3e50;
/* Chart colors */
--chart-1: #e07256;
--chart-2: #2d7d7d;
--chart-3: #c9a227;
--chart-4: #7b68a6;
--chart-5: #3498db;
--border-color: #e5dfd5;
--shadow-soft: 0 2px 8px rgba(26, 22, 18, 0.06);
--shadow-medium: 0 4px 20px rgba(26, 22, 18, 0.1);
--shadow-strong: 0 8px 40px rgba(26, 22, 18, 0.15);
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 16px;
--radius-xl: 24px;
--transition: 0.2s ease;
--transition-slow: 0.4s ease;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
min-height: 100vh;
}
/* Subtle noise texture overlay */
.noise-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
opacity: 0.03;
z-index: 1000;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
}
/* Header */
.header {
background: var(--bg-card);
border-bottom: 1px solid var(--border-color);
position: sticky;
top: 0;
z-index: 100;
}
.header-content {
max-width: 1400px;
margin: 0 auto;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
display: flex;
align-items: center;
gap: 0.75rem;
}
.logo-icon {
width: 40px;
height: 40px;
color: var(--accent-coral);
}
.logo-text {
display: flex;
flex-direction: column;
}
.logo-title {
font-family: 'Playfair Display', Georgia, serif;
font-size: 1.25rem;
font-weight: 700;
color: var(--text-primary);
line-height: 1.2;
}
.logo-subtitle {
font-size: 0.7rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.1em;
}
.nav {
display: flex;
gap: 0.5rem;
}
.nav-link {
padding: 0.6rem 1.2rem;
text-decoration: none;
color: var(--text-secondary);
font-weight: 500;
font-size: 0.9rem;
border-radius: var(--radius-md);
transition: var(--transition);
}
.nav-link:hover {
color: var(--text-primary);
background: var(--bg-secondary);
}
.nav-link.active {
background: var(--bg-accent);
color: var(--text-inverse);
}
/* Main Content */
.main {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
.view {
display: none;
animation: fadeIn 0.3s ease;
}
.view.active {
display: block;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* Hero Section */
.hero {
text-align: center;
padding: 3rem 0 2rem;
}
.hero-title {
font-family: 'Playfair Display', Georgia, serif;
font-size: clamp(2rem, 5vw, 3.5rem);
font-weight: 700;
color: var(--text-primary);
margin-bottom: 0.75rem;
line-height: 1.2;
}
.hero-subtitle {
font-size: 1.1rem;
color: var(--text-secondary);
max-width: 600px;
margin: 0 auto;
}
/* Search Section */
.search-section {
max-width: 700px;
margin: 2rem auto 3rem;
}
.search-container {
position: relative;
margin-bottom: 1rem;
}
.search-input {
width: 100%;
padding: 1rem 1.25rem 1rem 3.5rem;
font-size: 1rem;
font-family: inherit;
border: 2px solid var(--border-color);
border-radius: var(--radius-lg);
background: var(--bg-card);
color: var(--text-primary);
transition: var(--transition);
}
.search-input:focus {
outline: none;
border-color: var(--accent-coral);
box-shadow: 0 0 0 4px rgba(224, 114, 86, 0.1);
}
.search-input::placeholder {
color: var(--text-muted);
}
.search-icon {
position: absolute;
left: 1.25rem;
top: 50%;
transform: translateY(-50%);
width: 20px;
height: 20px;
color: var(--text-muted);
}
.filter-row {
display: flex;
gap: 1rem;
justify-content: center;
}
.filter-select {
padding: 0.6rem 2rem 0.6rem 1rem;
font-size: 0.9rem;
font-family: inherit;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-card);
color: var(--text-primary);
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%235c564d' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
transition: var(--transition);
}
.filter-select:focus {
outline: none;
border-color: var(--accent-teal);
}
/* Schools Grid */
.schools-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.5rem;
}
.school-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 1.5rem;
cursor: pointer;
transition: var(--transition);
position: relative;
overflow: hidden;
}
.school-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background: var(--accent-coral);
transform: scaleY(0);
transition: var(--transition);
}
.school-card:hover {
border-color: var(--accent-coral);
box-shadow: var(--shadow-medium);
transform: translateY(-2px);
}
.school-card:hover::before {
transform: scaleY(1);
}
.school-name {
font-family: 'Playfair Display', Georgia, serif;
font-size: 1.15rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.5rem;
}
.school-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
.school-tag {
font-size: 0.75rem;
padding: 0.25rem 0.6rem;
background: var(--bg-secondary);
color: var(--text-secondary);
border-radius: var(--radius-sm);
}
.school-tag.type {
background: rgba(45, 125, 125, 0.1);
color: var(--accent-teal);
}
.school-address {
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 1rem;
}
.school-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
}
.stat {
text-align: center;
}
.stat-value {
font-size: 1.25rem;
font-weight: 700;
color: var(--text-primary);
}
.stat-value.positive {
color: var(--accent-teal);
}
.stat-value.negative {
color: var(--accent-coral);
}
.stat-label {
font-size: 0.7rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Section Titles */
.section-title {
font-family: 'Playfair Display', Georgia, serif;
font-size: 2rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 0.5rem;
}
.section-subtitle {
font-size: 1rem;
color: var(--text-secondary);
margin-bottom: 2rem;
}
/* Compare View */
.compare-header {
text-align: center;
padding: 2rem 0;
}
.selected-schools {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 2rem;
min-height: 100px;
padding: 1.5rem;
background: var(--bg-secondary);
border-radius: var(--radius-lg);
justify-content: center;
align-items: center;
}
.empty-selection {
text-align: center;
color: var(--text-muted);
}
.empty-icon {
width: 48px;
height: 48px;
margin: 0 auto 0.5rem;
color: var(--text-muted);
}
.selected-school-tag {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1rem;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
font-size: 0.9rem;
color: var(--text-primary);
animation: slideIn 0.2s ease;
}
@keyframes slideIn {
from { opacity: 0; transform: scale(0.9); }
to { opacity: 1; transform: scale(1); }
}
.selected-school-tag .remove {
width: 18px;
height: 18px;
border: none;
background: var(--bg-secondary);
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition);
}
.selected-school-tag .remove:hover {
background: var(--accent-coral);
color: white;
}
.compare-search-section {
max-width: 500px;
margin: 0 auto 2rem;
position: relative;
}
.compare-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
box-shadow: var(--shadow-medium);
max-height: 300px;
overflow-y: auto;
z-index: 50;
display: none;
}
.compare-results.active {
display: block;
}
.compare-result-item {
padding: 0.75rem 1rem;
cursor: pointer;
border-bottom: 1px solid var(--border-color);
transition: var(--transition);
}
.compare-result-item:last-child {
border-bottom: none;
}
.compare-result-item:hover {
background: var(--bg-secondary);
}
.compare-result-item .name {
font-weight: 500;
color: var(--text-primary);
}
.compare-result-item .location {
font-size: 0.8rem;
color: var(--text-muted);
}
/* Charts Section */
.charts-section {
margin-top: 2rem;
}
.metric-selector {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
.metric-selector label {
font-weight: 500;
color: var(--text-secondary);
}
.chart-container {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 2rem;
margin-bottom: 2rem;
}
/* Data Table */
.data-table-container {
overflow-x: auto;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.data-table th {
background: var(--bg-secondary);
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
}
.data-table tr:last-child td {
border-bottom: none;
}
.data-table tr:hover td {
background: rgba(224, 114, 86, 0.03);
}
/* Rankings View */
.rankings-header {
text-align: center;
padding: 2rem 0;
}
.rankings-controls {
display: flex;
justify-content: center;
gap: 1rem;
margin-bottom: 2rem;
}
.rankings-list {
max-width: 800px;
margin: 0 auto;
}
.ranking-item {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 1.25rem 1.5rem;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
margin-bottom: 0.75rem;
transition: var(--transition);
cursor: pointer;
}
.ranking-item:hover {
border-color: var(--accent-coral);
box-shadow: var(--shadow-soft);
}
.ranking-position {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-weight: 700;
font-size: 1rem;
}
.ranking-position.top-3 {
background: linear-gradient(135deg, var(--accent-gold), #d4af37);
color: white;
}
.ranking-position:not(.top-3) {
background: var(--bg-secondary);
color: var(--text-secondary);
}
.ranking-info {
flex: 1;
}
.ranking-name {
font-family: 'Playfair Display', Georgia, serif;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.25rem;
}
.ranking-location {
font-size: 0.85rem;
color: var(--text-muted);
}
.ranking-score {
text-align: right;
}
.ranking-score-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--accent-teal);
}
.ranking-score-label {
font-size: 0.7rem;
color: var(--text-muted);
text-transform: uppercase;
}
/* Modal */
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 200;
display: none;
align-items: center;
justify-content: center;
padding: 2rem;
}
.modal.active {
display: flex;
}
.modal-backdrop {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(26, 22, 18, 0.6);
backdrop-filter: blur(4px);
}
.modal-content {
position: relative;
background: var(--bg-card);
border-radius: var(--radius-xl);
max-width: 800px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
box-shadow: var(--shadow-strong);
animation: modalIn 0.3s ease;
}
@keyframes modalIn {
from { opacity: 0; transform: scale(0.95) translateY(20px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.modal-close {
position: absolute;
top: 1rem;
right: 1rem;
width: 40px;
height: 40px;
border: none;
background: var(--bg-secondary);
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition);
z-index: 10;
}
.modal-close:hover {
background: var(--accent-coral);
color: white;
}
.modal-close svg {
width: 20px;
height: 20px;
}
.modal-header {
padding: 2rem 2rem 1rem;
border-bottom: 1px solid var(--border-color);
}
.modal-header h2 {
font-family: 'Playfair Display', Georgia, serif;
font-size: 1.75rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 0.5rem;
padding-right: 3rem;
}
.modal-meta {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.modal-body {
padding: 2rem;
}
.modal-chart-container {
margin-bottom: 2rem;
}
.modal-stats {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.modal-stats-section {
padding: 1rem;
background: var(--bg-secondary);
border-radius: var(--radius-md);
}
.modal-stats-section h4 {
margin: 0 0 0.75rem 0;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.modal-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
gap: 0.75rem;
}
.modal-stat {
text-align: center;
padding: 1rem;
background: var(--bg-secondary);
border-radius: var(--radius-md);
}
.modal-stat-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
}
.modal-stat-label {
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
margin-top: 0.25rem;
}
.modal-footer {
padding: 1.5rem 2rem;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
}
/* Buttons */
.btn {
padding: 0.75rem 1.5rem;
font-size: 0.9rem;
font-family: inherit;
font-weight: 600;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: var(--transition);
}
.btn-primary {
background: var(--accent-coral);
color: white;
}
.btn-primary:hover {
background: var(--accent-coral-dark);
transform: translateY(-1px);
}
/* Footer */
.footer {
text-align: center;
padding: 2rem;
margin-top: 3rem;
border-top: 1px solid var(--border-color);
color: var(--text-muted);
font-size: 0.85rem;
}
.footer a {
color: var(--accent-teal);
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
.footer-note {
margin-top: 0.5rem;
font-size: 0.75rem;
}
/* Loading State */
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: var(--text-muted);
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border-color);
border-top-color: var(--accent-coral);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Empty State */
.empty-state {
text-align: center;
padding: 4rem 2rem;
color: var(--text-muted);
}
.empty-state svg {
width: 64px;
height: 64px;
margin-bottom: 1rem;
opacity: 0.5;
}
/* Responsive */
@media (max-width: 768px) {
.header-content {
flex-direction: column;
gap: 1rem;
}
.nav {
width: 100%;
justify-content: center;
}
.main {
padding: 1rem;
}
.hero-title {
font-size: 1.75rem;
}
.filter-row {
flex-direction: column;
}
.schools-grid {
grid-template-columns: 1fr;
}
.modal-content {
margin: 1rem;
max-height: calc(100vh - 2rem);
}
.rankings-controls {
flex-direction: column;
align-items: stretch;
}
}