Files
school_compare/frontend/app.js
Tudor 7919c7b8a5
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 56s
Add year indicators to school modal sections
Display the data year for Progress Scores and School Context sections
in the school details modal, matching the existing KS2 Results format.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 09:56:45 +00:00

2150 lines
69 KiB
JavaScript

/**
* 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 `<button class="info-trigger" type="button" data-term="${termKey}" aria-label="${label}" aria-expanded="false"><svg class="info-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><circle cx="8" cy="8" r="6.5"/><path d="M8 7v4"/><circle cx="8" cy="5" r="0.5" fill="currentColor" stroke="none"/></svg></button>`;
}
/**
* 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 `<button class="warning-trigger" type="button" data-warning="${warningKey}" aria-label="${label}" aria-expanded="false"><svg class="warning-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M8 1.5L14.5 13H1.5L8 1.5z"/><path d="M8 6v3"/><circle cx="8" cy="11" r="0.5" fill="currentColor" stroke="none"/></svg></button>`;
}
// Map instances (stored to allow cleanup)
const schoolMaps = new Map();
// Helper to get chart aspect ratio based on screen size
function getChartAspectRatio() {
if (window.innerWidth <= 480) return 0.9; // Very small screens - taller chart
if (window.innerWidth <= 768) return 1.1; // Mobile - taller chart
return 2;
}
// Helper to check if we're on mobile
function isMobile() {
return window.innerWidth <= 768;
}
// =============================================================================
// 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 =
'<div class="loading"><div class="loading-spinner"></div><p>Loading school data...</p></div>';
}
break;
}
}
function renderLoadingSkeleton(count, type = "card") {
if (type === "ranking") {
return Array(count)
.fill(0)
.map(
() => `
<div class="ranking-item skeleton">
<div class="skeleton-circle"></div>
<div class="skeleton-content">
<div class="skeleton-line" style="width: 60%"></div>
<div class="skeleton-line short" style="width: 30%"></div>
</div>
<div class="skeleton-score"></div>
</div>
`,
)
.join("");
}
return Array(count)
.fill(0)
.map(
() => `
<div class="school-card skeleton">
<div class="skeleton-line" style="width: 70%"></div>
<div class="skeleton-line short" style="width: 40%"></div>
<div class="skeleton-line" style="width: 90%"></div>
</div>
`,
)
.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 = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
<span>Showing schools within ${searchLocation.radius} miles of <strong>${searchLocation.postcode.toUpperCase()}</strong></span>
`;
// 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 = `
<div class="featured-header">
<h3>Featured Schools</h3>
<p>Start typing to search schools across England</p>
</div>
${schools
.map((school) => {
// Religious denomination tag (only show if meaningful)
const faithTag = school.religious_denomination &&
!["None", "Does not apply", ""].includes(school.religious_denomination)
? `<span class="school-tag faith">${escapeHtml(school.religious_denomination)}</span>`
: "";
// Age range display
const ageRange = school.age_range
? `<span class="age-range">Ages ${escapeHtml(school.age_range)}</span>`
: "";
// Map container (only if coordinates available)
const hasCoords = school.latitude && school.longitude;
const mapContainer = hasCoords
? `<div class="school-map" data-lat="${school.latitude}" data-lng="${school.longitude}" data-name="${escapeHtml(school.school_name)}"></div>`
: "";
return `
<div class="school-card featured" 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>
${faithTag}
</div>
<div class="school-address">${escapeHtml(school.address || "")}</div>
${ageRange ? `<div class="school-details">${ageRange}</div>` : ""}
<div class="school-stats">
<div class="stat">
<div class="stat-value">
${formatMetricValue(school.rwm_expected_pct, "rwm_expected_pct")}
${getTrendIndicator(school.rwm_expected_pct, school.prev_rwm_expected_pct)}
</div>
<div class="stat-label"><span class="stat-label-with-info">RWM Expected${createInfoTrigger("rwm_expected")}</span></div>
</div>
<div class="stat">
<div class="stat-value">${formatMetricValue(school.rwm_high_pct, "rwm_high_pct")}</div>
<div class="stat-label"><span class="stat-label-with-info">RWM Higher${createInfoTrigger("rwm_higher")}</span></div>
</div>
<div class="stat">
<div class="stat-value">${school.total_pupils || "-"}</div>
<div class="stat-label"><span class="stat-label-with-info">Pupils${createInfoTrigger("total_pupils")}</span></div>
</div>
</div>
${mapContainer}
</div>
`;
})
.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 `<span class="trend-indicator trend-up" title="Up ${diff.toFixed(0)}% from last year">&#9650;</span>`;
} else if (diff <= -threshold) {
return `<span class="trend-indicator trend-down" title="Down ${Math.abs(diff).toFixed(0)}% from last year">&#9660;</span>`;
} else {
return `<span class="trend-indicator trend-stable" title="Stable (${diff >= 0 ? '+' : ''}${diff.toFixed(0)}%)">&#9644;</span>`;
}
}
// =============================================================================
// 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 = `
<div class="map-modal">
<div class="map-modal-header">
<h3>${escapeHtml(schoolName)}</h3>
<button class="map-modal-close" aria-label="Close map">&times;</button>
</div>
<div class="map-modal-content" id="fullscreen-map"></div>
</div>
`;
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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).addTo(fullMap);
const marker = L.marker([lat, lng]).addTo(fullMap);
marker.bindPopup(`<strong>${escapeHtml(schoolName)}</strong>`).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
? `<span class="distance-badge">${school.distance.toFixed(1)} mi</span>`
: "";
// Religious denomination tag (only show if meaningful)
const faithTag = school.religious_denomination &&
!["None", "Does not apply", ""].includes(school.religious_denomination)
? `<span class="school-tag faith">${escapeHtml(school.religious_denomination)}</span>`
: "";
// Age range display
const ageRange = school.age_range
? `<span class="age-range">Ages ${escapeHtml(school.age_range)}</span>`
: "";
// Map container (only if coordinates available)
const hasCoords = school.latitude && school.longitude;
const mapContainer = hasCoords
? `<div class="school-map" data-lat="${school.latitude}" data-lng="${school.longitude}" data-name="${escapeHtml(school.school_name)}"></div>`
: "";
return `
<div class="school-card" data-urn="${school.urn}">
<h3 class="school-name">${escapeHtml(school.school_name)}${distanceBadge}</h3>
<div class="school-meta">
<span class="school-tag">${escapeHtml(school.local_authority || "")}</span>
<span class="school-tag type">${escapeHtml(school.school_type || "")}</span>
${faithTag}
</div>
<div class="school-address">${escapeHtml(school.address || "")}</div>
${ageRange ? `<div class="school-details">${ageRange}</div>` : ""}
<div class="school-stats">
<div class="stat">
<div class="stat-value">
${formatMetricValue(school.rwm_expected_pct, "rwm_expected_pct")}
${getTrendIndicator(school.rwm_expected_pct, school.prev_rwm_expected_pct)}
</div>
<div class="stat-label"><span class="stat-label-with-info">RWM Expected${createInfoTrigger("rwm_expected")}</span></div>
</div>
<div class="stat">
<div class="stat-value">${formatMetricValue(school.rwm_high_pct, "rwm_high_pct")}</div>
<div class="stat-label"><span class="stat-label-with-info">RWM Higher${createInfoTrigger("rwm_higher")}</span></div>
</div>
<div class="stat">
<div class="stat-value">${school.total_pupils || "-"}</div>
<div class="stat-label"><span class="stat-label-with-info">Pupils${createInfoTrigger("total_pupils")}</span></div>
</div>
</div>
${mapContainer}
</div>
`;
})
.join("");
// Add pagination info
if (state.pagination.totalPages > 1) {
html += `
<div class="pagination-info">
<span>Showing ${schools.length} of ${state.pagination.total} schools</span>
${
state.pagination.page < state.pagination.totalPages
? `<button class="btn-load-more" onclick="loadMoreSchools()">Load More</button>`
: ""
}
</div>
`;
}
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 `
<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">${formatMetricValue(value, metric)}</div>
<div class="ranking-score-label">${getMetricLabel(metric, true)}</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 (state.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 = state.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 (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 = "<th>School</th>";
years.forEach((year) => {
headerHtml += `<th>${year}</th>`;
});
if (prevYear) {
headerHtml += `<th title="Change from ${prevYear} to ${lastYear}">Δ 1yr</th>`;
}
if (years.length > 2) {
headerHtml += `<th title="Standard deviation of scores (lower = more consistent)">Variability</th>`;
}
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 += `<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 != null ? formatMetricValue(value, metric) : "-"}</td>`;
});
if (prevYear) {
bodyHtml += `<td class="${oneYearClass}">${oneYearChangeStr !== "N/A" ? (oneYearChange >= 0 ? "+" : "") + oneYearChangeStr : oneYearChangeStr}</td>`;
}
if (years.length > 2) {
bodyHtml += `<td>${variabilityStr}</td>`;
}
bodyHtml += `</tr>`;
});
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 =
'<div class="loading"><div class="loading-spinner"></div><p>Loading school data...</p></div>';
elements.modalSchoolName.textContent = "Loading...";
elements.modalMeta.innerHTML = "";
const data = await loadSchoolDetails(urn);
if (!data) {
elements.modalStats.innerHTML =
'<div class="empty-state"><p>Unable to load school data</p></div>';
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
? `<span class="school-tag faith">${escapeHtml(faithDenom)}</span>`
: "";
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>
${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 += `<div class="modal-address">${escapeHtml(address)}</div>`;
}
if (ageRange) {
detailsHtml += `<div class="modal-age-range">Ages ${escapeHtml(ageRange)}</div>`;
}
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 = `
<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")} ${getTrendIndicator(latest.rwm_expected_pct, prevRwm)}</div>
<div class="modal-stat-label">RWM Expected${createInfoTrigger("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${createInfoTrigger("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${createInfoTrigger("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${createInfoTrigger("science_expected")}</div>
</div>
</div>
</div>
<div class="modal-stats-section">
<h4>Progress Scores (${latest.year}) ${createWarningTrigger("progress_scores_unavailable")}</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${createInfoTrigger("reading_progress")}</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${createInfoTrigger("writing_progress")}</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${createInfoTrigger("maths_progress")}</div>
</div>
</div>
</div>
<div class="modal-stats-section">
<h4>School Context (${latest.year})</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${createInfoTrigger("total_pupils")}</div>
</div>
<div class="modal-stat">
<div class="modal-stat-value">${formatMetricValue(latest.eal_pct, "eal_pct")}</div>
<div class="modal-stat-label">% EAL${createInfoTrigger("eal_pct")}</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${createInfoTrigger("sen_support_pct")}</div>
</div>
</div>
</div>
`;
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");
const mobile = isMobile();
schoolDetailChart = new Chart(ctx, {
type: "bar",
data: {
labels: years,
datasets: [
{
label: mobile ? "Reading" : "Reading %",
data: validData.map((d) => d.reading_expected_pct),
backgroundColor: "#2d7d7d",
borderRadius: 4,
},
{
label: mobile ? "Writing" : "Writing %",
data: validData.map((d) => d.writing_expected_pct),
backgroundColor: "#c9a227",
borderRadius: 4,
},
{
label: mobile ? "Maths" : "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",
size: mobile ? 11 : 12,
},
usePointStyle: true,
pointStyle: "circle",
padding: mobile ? 12 : 16,
boxWidth: mobile ? 8 : 12,
},
},
title: {
display: true,
text: mobile
? "KS2 Attainment (% expected)"
: "KS2 Attainment Over Time (% meeting expected standard)",
font: {
family: "'Playfair Display', serif",
size: mobile ? 14 : 16,
weight: 600,
},
padding: {
bottom: mobile ? 12 : 16,
},
},
tooltip: {
callbacks: {
label: function(context) {
return `${context.dataset.label}: ${context.parsed.y}%`;
}
}
},
},
scales: {
y: {
beginAtZero: true,
max: 100,
grid: { color: "#e5dfd5" },
ticks: {
font: { size: mobile ? 10 : 12 },
callback: function(value) {
return value + "%";
},
},
},
x: {
grid: { display: false },
ticks: {
font: { size: mobile ? 10 : 12 },
},
},
},
},
});
// 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: "&copy; 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 = `
<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;
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 = `
<div class="compare-results-header">
<span>${results.length} school${results.length !== 1 ? "s" : ""} found</span>
<button class="compare-results-close" title="Close (Esc)">
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 4L4 12M4 4l8 8"/>
</svg>
</button>
</div>
`;
if (results.length === 0) {
elements.compareResults.innerHTML =
headerHtml +
'<div class="compare-result-item"><span class="name">No more schools to add</span></div>';
} else {
elements.compareResults.innerHTML =
headerHtml +
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", (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 += `<div class="tooltip-title">${definition.title}</div>`;
}
content += `<div class="tooltip-description">${definition.description}</div>`;
if (definition.note) {
content += `<div class="tooltip-note">${definition.note}</div>`;
}
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;