/** * SchoolCompare.co.uk - Frontend Application * Interactive UK Primary School Performance Comparison */ const API_BASE = ''; // ============================================================================= // STATE MANAGEMENT // ============================================================================= const state = { schools: [], selectedSchools: [], currentSchoolData: null, filters: null, // Cached filter data metrics: null, // Cached metric definitions pagination: { page: 1, pageSize: 50, total: 0, totalPages: 0 }, isShowingFeatured: true, // Whether showing featured schools vs search results locationSearch: { active: false, postcode: null, radius: 5, }, loading: { schools: false, filters: false, rankings: false, comparison: false, modal: false, }, }; // Charts let comparisonChart = null; let schoolDetailChart = 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'), postcodeSearch: document.getElementById('postcode-search'), radiusSelect: document.getElementById('radius-select'), locationSearchBtn: document.getElementById('location-search-btn'), clearLocationBtn: document.getElementById('clear-location-btn'), 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'), rankingArea: document.getElementById('ranking-area'), 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'), }; // ============================================================================= // API & CACHING // ============================================================================= const apiCache = new Map(); const CACHE_TTL = 5 * 60 * 1000; // 5 minutes async function fetchAPI(endpoint, options = {}) { const { useCache = true, showLoading = null } = options; const cacheKey = endpoint; // Check cache if (useCache && apiCache.has(cacheKey)) { const cached = apiCache.get(cacheKey); if (Date.now() - cached.timestamp < CACHE_TTL) { return cached.data; } apiCache.delete(cacheKey); } // Set loading state if (showLoading) { state.loading[showLoading] = true; updateLoadingUI(showLoading); } try { const response = await fetch(`${API_BASE}${endpoint}`); if (!response.ok) throw new Error(`HTTP ${response.status}`); const data = await response.json(); // Cache the response if (useCache) { apiCache.set(cacheKey, { data, timestamp: Date.now() }); } return data; } catch (error) { console.error(`API Error (${endpoint}):`, error); return null; } finally { if (showLoading) { state.loading[showLoading] = false; updateLoadingUI(showLoading); } } } function updateLoadingUI(section) { // Update UI based on loading state switch (section) { case 'schools': if (state.loading.schools) { elements.schoolsGrid.innerHTML = renderLoadingSkeleton(6); } break; case 'rankings': if (state.loading.rankings) { elements.rankingsList.innerHTML = renderLoadingSkeleton(5, 'ranking'); } break; case 'modal': if (state.loading.modal) { elements.modalStats.innerHTML = '

Loading school data...

'; } break; } } function renderLoadingSkeleton(count, type = 'card') { if (type === 'ranking') { return Array(count).fill(0).map(() => `
`).join(''); } return Array(count).fill(0).map(() => `
`).join(''); } // ============================================================================= // ROUTING // ============================================================================= const routes = { '/': 'dashboard', '/compare': 'compare', '/rankings': 'rankings', }; function navigateTo(path) { // Update URL without reload window.history.pushState({}, '', path); handleRoute(); } function handleRoute() { const path = window.location.pathname; const view = routes[path] || 'dashboard'; // Update navigation document.querySelectorAll('.nav-link').forEach(link => { link.classList.toggle('active', link.dataset.view === view); }); // Update view document.querySelectorAll('.view').forEach(v => v.classList.remove('active')); const viewElement = document.getElementById(`${view}-view`); if (viewElement) { viewElement.classList.add('active'); } } // ============================================================================= // INITIALIZATION // ============================================================================= document.addEventListener('DOMContentLoaded', init); async function init() { // Load filters and metrics in parallel (single request for filters) const [filtersData, metricsData] = await Promise.all([ fetchAPI('/api/filters', { showLoading: 'filters' }), fetchAPI('/api/metrics'), ]); // Cache and apply filters if (filtersData) { state.filters = filtersData; populateFilters(filtersData); } // Cache metrics if (metricsData) { state.metrics = metricsData.metrics; } // Load initial data await loadSchools(); await loadRankings(); setupEventListeners(); // Handle initial route handleRoute(); // Handle browser back/forward window.addEventListener('popstate', handleRoute); } function populateFilters(data) { // Populate local authority filter (dashboard) 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); }); // Populate ranking area dropdown data.local_authorities.forEach(la => { const option = document.createElement('option'); option.value = la; option.textContent = la; elements.rankingArea.appendChild(option); }); // Populate ranking year dropdown 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); }); } // ============================================================================= // DATA LOADING // ============================================================================= async function loadSchools() { const search = elements.schoolSearch.value.trim(); const localAuthority = elements.localAuthorityFilter.value; const type = elements.typeFilter.value; const { active: locationActive, postcode, radius } = state.locationSearch; // If no search query (or less than 2 chars) and no filters and no location search, show featured schools if (search.length < 2 && !localAuthority && !type && !locationActive) { await loadFeaturedSchools(); return; } const params = new URLSearchParams(); if (search.length >= 2) params.append('search', search); if (localAuthority) params.append('local_authority', localAuthority); if (type) params.append('school_type', type); // Add location search params if (locationActive && postcode) { params.append('postcode', postcode); params.append('radius', radius); } params.append('page', state.pagination.page); params.append('page_size', state.pagination.pageSize); const queryString = params.toString(); const endpoint = `/api/schools?${queryString}`; // Don't cache search results (they change based on input) const data = await fetchAPI(endpoint, { useCache: false, showLoading: 'schools' }); if (!data) { showEmptyState(elements.schoolsGrid, 'Unable to load schools'); return; } state.schools = data.schools; state.pagination.total = data.total; state.pagination.totalPages = data.total_pages; state.isShowingFeatured = false; // Show location info banner if location search is active updateLocationInfoBanner(data.search_location); renderSchools(state.schools); } async function loadFeaturedSchools() { // Clear location info when showing featured updateLocationInfoBanner(null); // Load a sample of schools and pick 3 random ones const data = await fetchAPI('/api/schools?page_size=100', { showLoading: 'schools' }); if (!data || !data.schools.length) { showEmptyState(elements.schoolsGrid, 'No schools available'); return; } // Shuffle and pick 3 random schools const shuffled = data.schools.sort(() => Math.random() - 0.5); state.schools = shuffled.slice(0, 3); state.isShowingFeatured = true; renderFeaturedSchools(state.schools); } function updateLocationInfoBanner(searchLocation) { // Remove existing banner if any const existingBanner = document.querySelector('.location-info'); if (existingBanner) { existingBanner.remove(); } if (!searchLocation) { return; } // Create location info banner const banner = document.createElement('div'); banner.className = 'location-info'; banner.innerHTML = ` Showing schools within ${searchLocation.radius} miles of ${searchLocation.postcode.toUpperCase()} `; // Insert banner before the schools grid elements.schoolsGrid.parentNode.insertBefore(banner, elements.schoolsGrid); } async function searchByLocation() { const postcode = elements.postcodeSearch.value.trim(); const radius = parseFloat(elements.radiusSelect.value); if (!postcode) { alert('Please enter a postcode'); return; } // Validate UK postcode format (basic check) const postcodeRegex = /^[A-Z]{1,2}[0-9][A-Z0-9]?\s*[0-9][A-Z]{2}$/i; if (!postcodeRegex.test(postcode)) { alert('Please enter a valid UK postcode (e.g. SW18 4TF)'); return; } // Update state state.locationSearch = { active: true, postcode: postcode, radius: radius, }; state.pagination.page = 1; // Show clear button elements.clearLocationBtn.style.display = 'inline-flex'; // Load schools with location filter await loadSchools(); } function clearLocationSearch() { state.locationSearch = { active: false, postcode: null, radius: 5, }; state.pagination.page = 1; // Clear input elements.postcodeSearch.value = ''; elements.radiusSelect.value = '5'; // Hide clear button elements.clearLocationBtn.style.display = 'none'; // Reload schools (will show featured if no other filters) loadSchools(); } function renderFeaturedSchools(schools) { elements.schoolsGrid.innerHTML = ` ${schools.map(school => ` `).join('')} `; // Add click handlers elements.schoolsGrid.querySelectorAll('.school-card').forEach(card => { card.addEventListener('click', () => { const urn = parseInt(card.dataset.urn); openSchoolModal(urn); }); }); } async function loadSchoolDetails(urn) { const data = await fetchAPI(`/api/schools/${urn}`, { showLoading: 'modal' }); return data; } async function loadComparison() { if (state.selectedSchools.length === 0) return null; const urns = state.selectedSchools.map(s => s.urn).join(','); const data = await fetchAPI(`/api/compare?urns=${urns}`, { useCache: false, showLoading: 'comparison' }); return data; } async function loadRankings() { const area = elements.rankingArea.value; const metric = elements.rankingMetric.value; const year = elements.rankingYear.value; let endpoint = `/api/rankings?metric=${metric}&limit=20`; if (year) endpoint += `&year=${year}`; if (area) endpoint += `&local_authority=${encodeURIComponent(area)}`; const data = await fetchAPI(endpoint, { useCache: false, showLoading: 'rankings' }); if (!data) { showEmptyState(elements.rankingsList, 'Unable to load rankings'); return; } renderRankings(data.rankings, metric); } // ============================================================================= // METRIC HELPERS // ============================================================================= function getMetricLabel(key, short = false) { if (state.metrics) { const metric = state.metrics.find(m => m.key === key); if (metric) { return short ? metric.short_name : metric.name; } } // Fallback labels return key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); } function formatMetricValue(value, metric) { if (value === null || value === undefined) return '-'; const metricDef = state.metrics?.find(m => m.key === metric); const type = metricDef?.type || (metric.includes('pct') ? 'percentage' : 'score'); if (metric.includes('progress')) { return (value >= 0 ? '+' : '') + value.toFixed(1); } if (type === 'percentage' || metric.includes('pct')) { return value.toFixed(0) + '%'; } return value.toFixed(1); } // ============================================================================= // RENDER FUNCTIONS // ============================================================================= function renderSchools(schools) { if (schools.length === 0) { const message = state.locationSearch.active ? `No schools found within ${state.locationSearch.radius} miles of ${state.locationSearch.postcode}` : 'No primary schools found matching your criteria'; showEmptyState(elements.schoolsGrid, message); return; } let html = schools.map(school => { const distanceBadge = school.distance !== undefined && school.distance !== null ? `${school.distance.toFixed(1)} mi` : ''; return `

${escapeHtml(school.school_name)}${distanceBadge}

${escapeHtml(school.local_authority || '')} ${escapeHtml(school.school_type || '')}
${escapeHtml(school.address || '')}
Primary
Phase
KS2
Data
`; }).join(''); // Add pagination info if (state.pagination.totalPages > 1) { html += `
Showing ${schools.length} of ${state.pagination.total} schools ${state.pagination.page < state.pagination.totalPages ? `` : ''}
`; } elements.schoolsGrid.innerHTML = html; // Add click handlers elements.schoolsGrid.querySelectorAll('.school-card').forEach(card => { card.addEventListener('click', () => { const urn = parseInt(card.dataset.urn); openSchoolModal(urn); }); }); } async function loadMoreSchools() { state.pagination.page++; 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); params.append('page', state.pagination.page); params.append('page_size', state.pagination.pageSize); const data = await fetchAPI(`/api/schools?${params.toString()}`, { useCache: false }); if (data && data.schools.length > 0) { state.schools = [...state.schools, ...data.schools]; renderSchools(state.schools); } } function renderRankings(rankings, metric) { if (rankings.length === 0) { showEmptyState(elements.rankingsList, 'No ranking data available for this year/metric'); return; } elements.rankingsList.innerHTML = rankings.map((school, index) => { const value = school[metric]; if (value === null || value === undefined) return ''; return `
${index + 1}
${escapeHtml(school.school_name)}
${escapeHtml(school.local_authority || '')}
${formatMetricValue(value, metric)}
${getMetricLabel(metric, true)}
`; }).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 (state.selectedSchools.length === 0) { elements.selectedSchools.innerHTML = `

Search and add schools to compare

`; elements.chartsSection.style.display = 'none'; return; } elements.selectedSchools.innerHTML = state.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 (state.selectedSchools.length === 0) return; const data = await loadComparison(); if (!data) return; const metric = elements.metricSelect.value; // Prepare chart data const datasets = []; const allYears = new Set(); state.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: getMetricLabel(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: getMetricLabel(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 let bodyHtml = ''; state.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) 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; } async function openSchoolModal(urn) { // Show loading state immediately elements.modal.classList.add('active'); document.body.style.overflow = 'hidden'; elements.modalStats.innerHTML = '

Loading school data...

'; elements.modalSchoolName.textContent = 'Loading...'; elements.modalMeta.innerHTML = ''; const data = await loadSchoolDetails(urn); if (!data) { elements.modalStats.innerHTML = '

Unable to load school data

'; return; } state.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 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 = ` `; function getProgressClass(value) { if (value === null || value === undefined) return ''; return value >= 0 ? 'positive' : 'negative'; } // Create chart 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 = state.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; } function closeModal() { elements.modal.classList.remove('active'); document.body.style.overflow = ''; state.currentSchoolData = null; } function addToComparison(school) { if (state.selectedSchools.some(s => s.urn === school.urn)) return; if (state.selectedSchools.length >= 5) { alert('Maximum 5 schools can be compared at once'); return; } state.selectedSchools.push(school); renderSelectedSchools(); } function removeFromComparison(urn) { state.selectedSchools = state.selectedSchools.filter(s => s.urn !== urn); renderSelectedSchools(); } function showEmptyState(container, message) { container.innerHTML = `

${escapeHtml(message)}

`; } 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; const path = view === 'dashboard' ? '/' : `/${view}`; navigateTo(path); }); }); // Search and filters let searchTimeout; elements.schoolSearch.addEventListener('input', () => { clearTimeout(searchTimeout); state.pagination.page = 1; // Reset to first page on new search searchTimeout = setTimeout(loadSchools, 300); }); elements.localAuthorityFilter.addEventListener('change', () => { state.pagination.page = 1; loadSchools(); }); elements.typeFilter.addEventListener('change', () => { state.pagination.page = 1; loadSchools(); }); // Location search if (elements.locationSearchBtn) { elements.locationSearchBtn.addEventListener('click', searchByLocation); } if (elements.clearLocationBtn) { elements.clearLocationBtn.addEventListener('click', clearLocationSearch); } if (elements.postcodeSearch) { elements.postcodeSearch.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); searchByLocation(); } }); } // Compare search let compareSearchTimeout; let lastCompareSearchData = null; function renderCompareResults(data) { if (!data) return; lastCompareSearchData = data; const results = data.schools.filter(s => !state.selectedSchools.some(sel => sel.urn === s.urn)); const headerHtml = `
${results.length} school${results.length !== 1 ? 's' : ''} found
`; 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); 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)}`, { useCache: false }); 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.rankingArea.addEventListener('change', loadRankings); 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 (!state.currentSchoolData) return; const urn = state.currentSchoolData.school_info.urn; const isSelected = state.selectedSchools.some(s => s.urn === urn); if (isSelected) { removeFromComparison(urn); elements.addToCompare.textContent = 'Add to Compare'; } else { addToComparison(state.currentSchoolData.school_info); elements.addToCompare.textContent = 'Remove from Compare'; } }); // Keyboard document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { if (elements.compareResults.classList.contains('active')) { elements.compareResults.classList.remove('active'); elements.compareSearch.value = ''; return; } closeModal(); } }); }