/** * 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 searchMode: "name", // "name" or "location" 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; let modalMap = null; // Chart colors const CHART_COLORS = [ "#e07256", // coral "#2d7d7d", // teal "#c9a227", // gold "#7b68a6", // purple "#3498db", // blue "#27ae60", // green "#e74c3c", // red "#9b59b6", // violet ]; // Term definitions for tooltips const TERM_DEFINITIONS = { rwm_expected: { title: "RWM Expected Standard", description: "The percentage of pupils meeting the expected standard in Reading, Writing and Maths combined at the end of Key Stage 2 (Year 6).", note: "National average: 61%", }, rwm_higher: { title: "RWM Higher Standard", description: "The percentage of pupils exceeding the expected standard and reaching the higher standard in Reading, Writing and Maths combined.", note: "National average: 8%", }, gps_expected: { title: "GPS Expected Standard", description: "The percentage of pupils meeting the expected standard in Grammar, Punctuation and Spelling at the end of Key Stage 2.", note: "National average: 72%", }, science_expected: { title: "Science Expected Standard", description: "The percentage of pupils meeting the expected standard in Science, assessed by teacher judgement at the end of Key Stage 2.", note: "National average: 80%", }, reading_progress: { title: "Reading Progress Score", description: "A value-added measure showing how much progress pupils made in Reading between KS1 and KS2, compared to pupils with similar starting points nationally.", note: "A score of 0 is average. Positive = above-average progress.", }, writing_progress: { title: "Writing Progress Score", description: "A value-added measure showing how much progress pupils made in Writing between KS1 and KS2, compared to pupils with similar starting points nationally.", note: "A score of 0 is average. Positive = above-average progress.", }, maths_progress: { title: "Maths Progress Score", description: "A value-added measure showing how much progress pupils made in Maths between KS1 and KS2, compared to pupils with similar starting points nationally.", note: "A score of 0 is average. Positive = above-average progress.", }, disadvantaged_pct: { title: "% Disadvantaged", description: "The percentage of pupils eligible for free school meals or who have been at any point in the last six years, or are looked-after children.", note: "Affects school funding through the Pupil Premium.", }, eal_pct: { title: "% EAL", description: "The percentage of pupils whose first language is known or believed to be other than English. These pupils may need additional language support.", note: null, }, sen_support_pct: { title: "% SEN Support", description: "The percentage of pupils receiving Special Educational Needs Support. These pupils need extra help but do not have an Education, Health and Care Plan.", note: "Does not include pupils with EHCPs.", }, total_pupils: { title: "Total Pupils", description: "The total number of pupils enrolled at the school.", note: null, }, }; // Warning definitions for alerts/notices const WARNING_DEFINITIONS = { progress_scores_unavailable: { title: "Progress Scores Unavailable", description: "The DfE will not publish primary school progress measures for 2023-24 or 2024-25, as KS1 SATs were cancelled in 2020 and 2021.", }, }; /** * Creates an info trigger button for a term tooltip * @param {string} termKey - Key from TERM_DEFINITIONS * @returns {string} HTML string for the info trigger */ function createInfoTrigger(termKey) { const definition = TERM_DEFINITIONS[termKey]; if (!definition) return ""; const label = `What is ${definition.title}?`; return ``; } /** * Creates a warning trigger button for warning tooltips * @param {string} warningKey - Key from WARNING_DEFINITIONS * @returns {string} HTML string for the warning trigger */ function createWarningTrigger(warningKey) { const definition = WARNING_DEFINITIONS[warningKey]; if (!definition) return ""; const label = definition.title; return ``; } // Map instances (stored to allow cleanup) const schoolMaps = new Map(); // Helper to get chart aspect ratio based on screen size function getChartAspectRatio() { return window.innerWidth <= 768 ? 1.2 : 2; } // ============================================================================= // DOM ELEMENTS // ============================================================================= const elements = { // Search mode toggle searchModeToggle: document.querySelector(".search-mode-toggle"), searchModeBtns: document.querySelectorAll(".search-mode-btn"), nameSearchPanel: document.getElementById("name-search-panel"), locationSearchPanel: document.getElementById("location-search-panel"), // Name search schoolSearch: document.getElementById("school-search"), localAuthorityFilter: document.getElementById("local-authority-filter"), typeFilter: document.getElementById("type-filter"), // Location search postcodeSearch: document.getElementById("postcode-search"), radiusSelect: document.getElementById("radius-select"), locationSearchBtn: document.getElementById("location-search-btn"), typeFilterLocation: document.getElementById("type-filter-location"), // Schools grid 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"), modalDetails: document.getElementById("modal-details"), modalStats: document.getElementById("modal-stats"), modalMapContainer: document.getElementById("modal-map-container"), modalMap: document.getElementById("modal-map"), 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 = { "/": "home", "/compare": "compare", "/rankings": "rankings", }; const pageTitles = { home: "SchoolCompare | Compare Primary School Performance", compare: "Compare Schools | SchoolCompare", rankings: "School Rankings | SchoolCompare", }; function navigateTo(path) { // Update URL without reload window.history.pushState({}, "", path); handleRoute(); } function handleRoute() { let path = window.location.pathname; // Normalize path - treat /index.html, empty, or just "/" as home if (path === "" || path === "/index.html" || path.endsWith("/index.html")) { path = "/"; } const view = routes[path] || "home"; // Update page title for SEO document.title = pageTitles[view] || pageTitles.home; // 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 selected schools from localStorage first loadSelectedSchoolsFromStorage(); try { // 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(); } catch (err) { console.error("Error during initialization:", err); } // Always set up event listeners and routing, even if data loading fails setupEventListeners(); // Initialize tooltip manager tooltipManager = new TooltipManager(); // Render any previously selected schools renderSelectedSchools(); // Handle initial route handleRoute(); // Handle browser back/forward window.addEventListener("popstate", handleRoute); } function populateFilters(data) { // Populate local authority filter (home page) data.local_authorities.forEach((la) => { const option = document.createElement("option"); option.value = la; option.textContent = la; elements.localAuthorityFilter.appendChild(option); }); // Populate school type filter (both name and location panels) data.school_types.forEach((type) => { const option = document.createElement("option"); option.value = type; option.textContent = type; elements.typeFilter.appendChild(option); // Also add to location panel's type filter const optionLocation = document.createElement("option"); optionLocation.value = type; optionLocation.textContent = type; elements.typeFilterLocation.appendChild(optionLocation); }); // 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 params = new URLSearchParams(); if (state.searchMode === "name") { // Name search mode const search = elements.schoolSearch.value.trim(); const localAuthority = elements.localAuthorityFilter.value; const type = elements.typeFilter.value; // If no search query (or less than 2 chars) and no filters, show featured schools if (search.length < 2 && !localAuthority && !type) { await loadFeaturedSchools(); return; } if (search.length >= 2) params.append("search", search); if (localAuthority) params.append("local_authority", localAuthority); if (type) params.append("school_type", type); } else { // Location search mode const { active: locationActive, postcode, radius } = state.locationSearch; const type = elements.typeFilterLocation.value; // If no location search active, show featured schools if (!locationActive) { await loadFeaturedSchools(); return; } params.append("postcode", postcode); params.append("radius", radius); if (type) params.append("school_type", type); } 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; // Load schools with location filter await loadSchools(); } function renderFeaturedSchools(schools) { elements.schoolsGrid.innerHTML = ` ${schools .map((school) => { // Religious denomination tag (only show if meaningful) const faithTag = school.religious_denomination && !["None", "Does not apply", ""].includes(school.religious_denomination) ? `${escapeHtml(school.religious_denomination)}` : ""; // Age range display const ageRange = school.age_range ? `Ages ${escapeHtml(school.age_range)}` : ""; // Map container (only if coordinates available) const hasCoords = school.latitude && school.longitude; const mapContainer = hasCoords ? `
` : ""; return ` `; }) .join("")} `; // Initialize maps initializeSchoolMaps(elements.schoolsGrid); // Add click handlers elements.schoolsGrid.querySelectorAll(".school-card").forEach((card) => { card.addEventListener("click", (e) => { // Don't trigger if clicking on map or info trigger if (e.target.closest(".school-map") || e.target.closest(".info-trigger")) return; 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); } /** * Calculate trend indicator based on current and previous year values * Returns HTML for trend arrow with class */ function getTrendIndicator(current, previous) { if (current === null || current === undefined || previous === null || previous === undefined) { return ""; } const diff = current - previous; const threshold = 2; // Minimum % change to show trend if (diff >= threshold) { return ``; } else if (diff <= -threshold) { return ``; } else { return ``; } } // ============================================================================= // MAP FUNCTIONS // ============================================================================= /** * Initialize Leaflet maps for all school cards in a container */ function initializeSchoolMaps(container) { // Check if Leaflet is loaded if (typeof L === "undefined") { console.warn("Leaflet not loaded yet, skipping map initialization"); return; } const mapElements = container.querySelectorAll(".school-map"); if (mapElements.length === 0) return; // Clean up existing maps first mapElements.forEach((mapEl) => { const existingMap = schoolMaps.get(mapEl); if (existingMap) { try { existingMap.remove(); } catch (e) { // Ignore cleanup errors } schoolMaps.delete(mapEl); } }); // Initialize new maps with a small delay to ensure DOM is ready setTimeout(() => { mapElements.forEach((mapEl) => { try { // Skip if already initialized if (schoolMaps.has(mapEl)) return; const lat = parseFloat(mapEl.dataset.lat); const lng = parseFloat(mapEl.dataset.lng); const schoolName = mapEl.dataset.name; if (isNaN(lat) || isNaN(lng)) return; // Ensure element has dimensions if (mapEl.offsetWidth === 0 || mapEl.offsetHeight === 0) { console.warn("Map container has no dimensions, skipping"); return; } // Create map const map = L.map(mapEl, { center: [lat, lng], zoom: 15, zoomControl: false, attributionControl: false, dragging: true, scrollWheelZoom: false, }); // Add tile layer (OpenStreetMap) L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 19, }).addTo(map); // Add marker const marker = L.marker([lat, lng]).addTo(map); marker.bindTooltip(schoolName, { permanent: false, direction: "top" }); // Store map reference schoolMaps.set(mapEl, map); // Handle click to open fullscreen mapEl.addEventListener("click", (e) => { e.stopPropagation(); openMapModal(lat, lng, schoolName); }); } catch (err) { console.error("Error initializing map:", err); } }); }, 100); } /** * Open fullscreen map modal */ function openMapModal(lat, lng, schoolName) { // Create modal overlay const overlay = document.createElement("div"); overlay.className = "map-modal-overlay"; overlay.innerHTML = `

${escapeHtml(schoolName)}

`; document.body.appendChild(overlay); document.body.style.overflow = "hidden"; // Initialize fullscreen map const mapContainer = document.getElementById("fullscreen-map"); const fullMap = L.map(mapContainer, { center: [lat, lng], zoom: 16, }); L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 19, attribution: '© OpenStreetMap', }).addTo(fullMap); const marker = L.marker([lat, lng]).addTo(fullMap); marker.bindPopup(`${escapeHtml(schoolName)}`).openPopup(); // Close handlers const closeModal = () => { fullMap.remove(); overlay.remove(); document.body.style.overflow = ""; }; overlay.querySelector(".map-modal-close").addEventListener("click", closeModal); overlay.addEventListener("click", (e) => { if (e.target === overlay) closeModal(); }); // Close on Escape key const escHandler = (e) => { if (e.key === "Escape") { closeModal(); document.removeEventListener("keydown", escHandler); } }; document.addEventListener("keydown", escHandler); } // ============================================================================= // 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` : ""; // Religious denomination tag (only show if meaningful) const faithTag = school.religious_denomination && !["None", "Does not apply", ""].includes(school.religious_denomination) ? `${escapeHtml(school.religious_denomination)}` : ""; // Age range display const ageRange = school.age_range ? `Ages ${escapeHtml(school.age_range)}` : ""; // Map container (only if coordinates available) const hasCoords = school.latitude && school.longitude; const mapContainer = hasCoords ? `
` : ""; return `

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

${escapeHtml(school.local_authority || "")} ${escapeHtml(school.school_type || "")} ${faithTag}
${escapeHtml(school.address || "")}
${ageRange ? `
${ageRange}
` : ""}
${formatMetricValue(school.rwm_expected_pct, "rwm_expected_pct")} ${getTrendIndicator(school.rwm_expected_pct, school.prev_rwm_expected_pct)}
RWM Expected${createInfoTrigger("rwm_expected")}
${formatMetricValue(school.rwm_high_pct, "rwm_high_pct")}
RWM Higher${createInfoTrigger("rwm_higher")}
${school.total_pupils || "-"}
Pupils${createInfoTrigger("total_pupils")}
${mapContainer}
`; }) .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; // Initialize maps initializeSchoolMaps(elements.schoolsGrid); // Add click handlers elements.schoolsGrid.querySelectorAll(".school-card").forEach((card) => { card.addEventListener("click", (e) => { // Don't trigger if clicking on map or info trigger if (e.target.closest(".school-map") || e.target.closest(".info-trigger")) return; const urn = parseInt(card.dataset.urn); openSchoolModal(urn); }); }); } async function loadMoreSchools() { state.pagination.page++; const params = new URLSearchParams(); if (state.searchMode === "name") { 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); } else { const { postcode, radius } = state.locationSearch; if (postcode) { params.append("postcode", postcode); params.append("radius", radius); } const type = elements.typeFilterLocation.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: getChartAspectRatio(), 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) { // Hide all school maps to prevent z-index overlap with modal document.querySelectorAll('.school-map').forEach(el => { el.style.visibility = 'hidden'; }); // 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; // Build meta tags including faith if applicable const faithDenom = data.school_info.religious_denomination; const showFaith = faithDenom && faithDenom !== "None" && faithDenom !== "Does not apply" && faithDenom !== ""; const faithTag = showFaith ? `${escapeHtml(faithDenom)}` : ""; elements.modalMeta.innerHTML = ` ${escapeHtml(data.school_info.local_authority || "")} ${escapeHtml(data.school_info.school_type || "")} ${faithTag} `; // Build details section (address and age range) const ageRange = data.school_info.age_range; const address = data.school_info.address; let detailsHtml = ""; if (address) { detailsHtml += ``; } if (ageRange) { detailsHtml += ``; } elements.modalDetails.innerHTML = detailsHtml; // 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]; // Get previous year for trend calculation const latestIndex = sortedData.indexOf(latest); const previous = sortedData[latestIndex + 1] || null; const prevRwm = previous?.rwm_expected_pct; 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: getChartAspectRatio(), 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; // Initialize modal map if coordinates available const lat = data.school_info.latitude; const lng = data.school_info.longitude; if (lat && lng && typeof L !== "undefined") { elements.modalMapContainer.style.display = "block"; // Destroy existing map if any if (modalMap) { modalMap.remove(); modalMap = null; } // Create map after a brief delay to ensure container is visible setTimeout(() => { try { modalMap = L.map(elements.modalMap, { scrollWheelZoom: false, }).setView([lat, lng], 15); L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { attribution: "© OpenStreetMap contributors", maxZoom: 19, }).addTo(modalMap); L.marker([lat, lng]).addTo(modalMap); // Handle click to open fullscreen map elements.modalMap.addEventListener("click", () => { openMapModal(lat, lng, data.school_info.school_name); }); } catch (err) { console.error("Error initializing modal map:", err); elements.modalMapContainer.style.display = "none"; } }, 100); } else { elements.modalMapContainer.style.display = "none"; } } function closeModal() { elements.modal.classList.remove("active"); document.body.style.overflow = ""; state.currentSchoolData = null; // Clean up modal map if (modalMap) { modalMap.remove(); modalMap = null; } // Restore visibility of school maps document.querySelectorAll('.school-map').forEach(el => { el.style.visibility = 'visible'; }); } 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); saveSelectedSchoolsToStorage(); renderSelectedSchools(); } function removeFromComparison(urn) { state.selectedSchools = state.selectedSchools.filter((s) => s.urn !== urn); saveSelectedSchoolsToStorage(); renderSelectedSchools(); } function saveSelectedSchoolsToStorage() { try { localStorage.setItem("selectedSchools", JSON.stringify(state.selectedSchools)); } catch (e) { console.warn("Failed to save to localStorage:", e); } } function loadSelectedSchoolsFromStorage() { try { const stored = localStorage.getItem("selectedSchools"); if (stored) { const schools = JSON.parse(stored); if (Array.isArray(schools)) { state.selectedSchools = schools; } } } catch (e) { console.warn("Failed to load from localStorage:", e); } } 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 === "home" ? "/" : `/${view}`; navigateTo(path); }); }); // Search mode toggle elements.searchModeBtns.forEach((btn) => { btn.addEventListener("click", () => { const mode = btn.dataset.mode; if (mode === state.searchMode) return; // Update state state.searchMode = mode; state.pagination.page = 1; // Update toggle buttons elements.searchModeBtns.forEach((b) => b.classList.remove("active")); btn.classList.add("active"); // Update panels elements.nameSearchPanel.classList.toggle("active", mode === "name"); elements.locationSearchPanel.classList.toggle( "active", mode === "location", ); // Clear the inactive mode's state if (mode === "name") { // Clear location search state state.locationSearch = { active: false, postcode: null, radius: 5 }; elements.postcodeSearch.value = ""; elements.radiusSelect.value = "5"; elements.typeFilterLocation.value = ""; updateLocationInfoBanner(null); } else { // Clear name search state elements.schoolSearch.value = ""; elements.localAuthorityFilter.value = ""; elements.typeFilter.value = ""; } // Reload schools (will show featured if no active search) loadSchools(); }); }); // Name 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.postcodeSearch) { elements.postcodeSearch.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); searchByLocation(); } }); } if (elements.typeFilterLocation) { elements.typeFilterLocation.addEventListener("change", () => { if (state.locationSearch.active) { state.pagination.page = 1; loadSchools(); } }); } // 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", (e) => { e.stopPropagation(); // Prevent click-outside handler from closing results 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"); } }); // Close compare results when clicking outside document.addEventListener("click", (e) => { if (!elements.compareResults.classList.contains("active")) return; // Don't close if clicking inside the search input or results if ( elements.compareSearch.contains(e.target) || elements.compareResults.contains(e.target) ) { return; } // Close the results elements.compareResults.classList.remove("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(); } }); } // ============================================================================= // TOOLTIP MANAGER // ============================================================================= class TooltipManager { constructor() { this.activeTooltip = null; this.showTimeout = null; this.hideTimeout = null; this.isTouchDevice = "ontouchstart" in window || navigator.maxTouchPoints > 0; this.init(); } init() { // Create tooltip container element (singleton) this.tooltipEl = document.createElement("div"); this.tooltipEl.className = "tooltip"; this.tooltipEl.setAttribute("role", "tooltip"); this.tooltipEl.setAttribute("aria-hidden", "true"); document.body.appendChild(this.tooltipEl); // Event delegation on document this.bindEvents(); } bindEvents() { if (this.isTouchDevice) { document.addEventListener("click", this.handleTouchClick.bind(this)); } else { document.addEventListener( "mouseenter", this.handleMouseEnter.bind(this), true ); document.addEventListener( "mouseleave", this.handleMouseLeave.bind(this), true ); document.addEventListener("focusin", this.handleFocusIn.bind(this)); document.addEventListener("focusout", this.handleFocusOut.bind(this)); } // Escape key closes tooltip document.addEventListener("keydown", (e) => { if (e.key === "Escape" && this.activeTooltip) { this.hide(); } }); } handleMouseEnter(e) { if (!e.target || !e.target.closest) return; const trigger = e.target.closest(".info-trigger, .warning-trigger"); if (!trigger) return; clearTimeout(this.hideTimeout); this.showTimeout = setTimeout(() => { this.show(trigger); }, 150); } handleMouseLeave(e) { if (!e.target || !e.target.closest) return; const trigger = e.target.closest(".info-trigger, .warning-trigger"); const tooltip = e.target.closest(".tooltip"); if (!trigger && !tooltip) return; // Check if moving between trigger and tooltip const relatedTarget = e.relatedTarget; if ( relatedTarget?.closest?.(".info-trigger, .warning-trigger") === this.activeTooltip || relatedTarget?.closest?.(".tooltip") ) { return; } clearTimeout(this.showTimeout); this.hideTimeout = setTimeout(() => { this.hide(); }, 100); } handleFocusIn(e) { if (!e.target || !e.target.closest) return; const trigger = e.target.closest(".info-trigger, .warning-trigger"); if (!trigger) return; clearTimeout(this.hideTimeout); this.show(trigger); } handleFocusOut(e) { if (!e.target || !e.target.closest) return; const trigger = e.target.closest(".info-trigger, .warning-trigger"); if (!trigger) return; this.hideTimeout = setTimeout(() => { this.hide(); }, 100); } handleTouchClick(e) { const trigger = e.target.closest(".info-trigger, .warning-trigger"); if (trigger) { e.preventDefault(); e.stopPropagation(); if (this.activeTooltip === trigger) { this.hide(); } else { this.show(trigger); } return; } // Tap outside closes tooltip if (this.activeTooltip && !e.target.closest(".tooltip")) { this.hide(); } } show(trigger) { // Check if it's an info trigger or warning trigger const isWarning = trigger.classList.contains("warning-trigger"); let definition; if (isWarning) { const warningKey = trigger.dataset.warning; definition = WARNING_DEFINITIONS[warningKey]; if (!definition) { console.warn(`No definition found for warning: ${warningKey}`); return; } this.tooltipEl.classList.add("tooltip-warning"); } else { const termKey = trigger.dataset.term; definition = TERM_DEFINITIONS[termKey]; if (!definition) { console.warn(`No definition found for term: ${termKey}`); return; } this.tooltipEl.classList.remove("tooltip-warning"); } // Build tooltip content let content = ""; if (definition.title) { content += `
${definition.title}
`; } content += `
${definition.description}
`; if (definition.note) { content += `
${definition.note}
`; } this.tooltipEl.innerHTML = content; // Make tooltip visible first so we can measure it this.tooltipEl.style.visibility = "hidden"; this.tooltipEl.style.opacity = "0"; this.tooltipEl.classList.add("visible"); // Position tooltip this.position(trigger); // Show tooltip with animation this.tooltipEl.style.visibility = ""; this.tooltipEl.style.opacity = ""; this.tooltipEl.setAttribute("aria-hidden", "false"); trigger.setAttribute("aria-expanded", "true"); this.activeTooltip = trigger; } hide() { if (!this.activeTooltip) return; this.tooltipEl.classList.remove("visible"); this.tooltipEl.setAttribute("aria-hidden", "true"); this.activeTooltip.setAttribute("aria-expanded", "false"); this.activeTooltip = null; } position(trigger) { const triggerRect = trigger.getBoundingClientRect(); const tooltipRect = this.tooltipEl.getBoundingClientRect(); // Determine placement: prefer top, fall back to bottom if not enough space const spaceAbove = triggerRect.top; const tooltipHeight = tooltipRect.height || 100; let placement = "top"; let top; if (spaceAbove < tooltipHeight + 20) { placement = "bottom"; top = triggerRect.bottom + 10 + window.scrollY; } else { top = triggerRect.top - tooltipHeight - 10 + window.scrollY; } // Horizontal centering with edge detection let left = triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2 + window.scrollX; // Prevent overflow on left if (left < 10) { left = 10; } // Prevent overflow on right const rightEdge = left + tooltipRect.width; if (rightEdge > window.innerWidth - 10) { left = window.innerWidth - tooltipRect.width - 10; } this.tooltipEl.style.top = `${top}px`; this.tooltipEl.style.left = `${left}px`; this.tooltipEl.dataset.placement = placement; } } // Global tooltip manager instance let tooltipManager = null;