All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 57s
- Move location-info banner above map view as full-width bar - Set fixed height for map view container with equal map/list heights - Add z-index to map to prevent overlap with sticky header - Update mobile responsive styles for consistent heights Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2448 lines
78 KiB
JavaScript
2448 lines
78 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,
|
|
coords: null, // Lat/lng of search location
|
|
},
|
|
resultsView: "list", // "list" or "map"
|
|
loading: {
|
|
schools: false,
|
|
filters: false,
|
|
rankings: false,
|
|
comparison: false,
|
|
modal: false,
|
|
},
|
|
};
|
|
|
|
// Charts
|
|
let comparisonChart = null;
|
|
let schoolDetailChart = null;
|
|
let modalMap = null;
|
|
let resultsMapInstance = null;
|
|
let resultsMapMarkers = new Map(); // Store markers by school URN
|
|
|
|
// 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"),
|
|
// Results view
|
|
viewToggle: document.getElementById("view-toggle"),
|
|
viewToggleBtns: document.querySelectorAll(".view-toggle-btn"),
|
|
resultsContainer: document.getElementById("results-container"),
|
|
resultsMap: document.getElementById("results-map"),
|
|
// 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;
|
|
|
|
// Store search coordinates if available
|
|
if (data.search_location && data.search_location.lat && data.search_location.lng) {
|
|
state.locationSearch.coords = {
|
|
lat: data.search_location.lat,
|
|
lng: data.search_location.lng,
|
|
};
|
|
}
|
|
|
|
// Show location info banner if location search is active
|
|
updateLocationInfoBanner(data.search_location);
|
|
|
|
// Render appropriate view based on current state
|
|
if (state.resultsView === "map" && state.locationSearch.active) {
|
|
renderCompactSchoolList(state.schools);
|
|
initializeResultsMap(state.schools);
|
|
} else {
|
|
renderSchools(state.schools);
|
|
}
|
|
|
|
// Update view toggle visibility
|
|
updateViewToggle();
|
|
}
|
|
|
|
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);
|
|
|
|
// Hide view toggle for featured schools
|
|
updateViewToggle();
|
|
}
|
|
|
|
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 results container (above map view)
|
|
elements.resultsContainer.parentNode.insertBefore(banner, elements.resultsContainer);
|
|
}
|
|
|
|
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">▲</span>`;
|
|
} else if (diff <= -threshold) {
|
|
return `<span class="trend-indicator trend-down" title="Down ${Math.abs(diff).toFixed(0)}% from last year">▼</span>`;
|
|
} else {
|
|
return `<span class="trend-indicator trend-stable" title="Stable (${diff >= 0 ? '+' : ''}${diff.toFixed(0)}%)">▬</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">×</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: '© <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);
|
|
}
|
|
|
|
/**
|
|
* Initialize the results map for location search
|
|
*/
|
|
function initializeResultsMap(schools) {
|
|
// Check if Leaflet is loaded
|
|
if (typeof L === "undefined") {
|
|
console.warn("Leaflet not loaded, skipping results map initialization");
|
|
return;
|
|
}
|
|
|
|
// Destroy existing map if any
|
|
if (resultsMapInstance) {
|
|
try {
|
|
resultsMapInstance.remove();
|
|
} catch (e) {
|
|
// Ignore cleanup errors
|
|
}
|
|
resultsMapInstance = null;
|
|
}
|
|
|
|
// Need search coords to center the map
|
|
if (!state.locationSearch.coords) {
|
|
console.warn("No search coordinates available for results map");
|
|
return;
|
|
}
|
|
|
|
const { lat, lng } = state.locationSearch.coords;
|
|
|
|
// Ensure container has dimensions
|
|
setTimeout(() => {
|
|
const mapContainer = elements.resultsMap;
|
|
if (!mapContainer || mapContainer.offsetWidth === 0 || mapContainer.offsetHeight === 0) {
|
|
console.warn("Results map container has no dimensions");
|
|
return;
|
|
}
|
|
|
|
// Create map centered on search location
|
|
resultsMapInstance = L.map(mapContainer, {
|
|
center: [lat, lng],
|
|
zoom: 14,
|
|
scrollWheelZoom: true,
|
|
});
|
|
|
|
// Add tile layer
|
|
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
|
maxZoom: 19,
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
|
}).addTo(resultsMapInstance);
|
|
|
|
// Create custom icon for search location (blue)
|
|
const searchIcon = L.divIcon({
|
|
className: "search-location-marker",
|
|
html: `<svg viewBox="0 0 24 24" fill="#3498db" stroke="#2980b9" stroke-width="1" width="32" height="32">
|
|
<circle cx="12" cy="12" r="8"/>
|
|
</svg>`,
|
|
iconSize: [32, 32],
|
|
iconAnchor: [16, 16],
|
|
});
|
|
|
|
// Add search location marker
|
|
L.marker([lat, lng], { icon: searchIcon })
|
|
.addTo(resultsMapInstance)
|
|
.bindPopup(`<strong>Search location</strong><br>${state.locationSearch.postcode}`);
|
|
|
|
// Clear existing markers
|
|
resultsMapMarkers.clear();
|
|
|
|
// Add school markers
|
|
const bounds = L.latLngBounds([[lat, lng]]);
|
|
schools.forEach((school) => {
|
|
if (school.latitude && school.longitude) {
|
|
const marker = L.marker([school.latitude, school.longitude])
|
|
.addTo(resultsMapInstance)
|
|
.bindPopup(`
|
|
<strong>${escapeHtml(school.school_name)}</strong><br>
|
|
${school.distance !== undefined ? school.distance.toFixed(1) + " miles away" : ""}
|
|
`);
|
|
|
|
// Store marker reference
|
|
resultsMapMarkers.set(school.urn, {
|
|
marker,
|
|
lat: school.latitude,
|
|
lng: school.longitude,
|
|
});
|
|
|
|
// Click handler to highlight card
|
|
marker.on("click", () => {
|
|
highlightSchoolCard(school.urn, false); // Don't center map, already at marker
|
|
});
|
|
|
|
bounds.extend([school.latitude, school.longitude]);
|
|
}
|
|
});
|
|
|
|
// Fit bounds to show all markers with padding
|
|
if (schools.length > 0) {
|
|
resultsMapInstance.fitBounds(bounds, { padding: [30, 30] });
|
|
}
|
|
}, 100);
|
|
}
|
|
|
|
/**
|
|
* Highlight a school card and scroll it into view
|
|
* @param {number} urn - School URN
|
|
* @param {boolean} centerMap - Whether to center the map on the school (default: true)
|
|
*/
|
|
function highlightSchoolCard(urn, centerMap = true) {
|
|
// Remove highlight from all cards and compact items
|
|
document.querySelectorAll(".school-card, .school-list-item").forEach((card) => {
|
|
card.classList.remove("highlighted");
|
|
});
|
|
|
|
// Add highlight to selected card/item
|
|
const card = document.querySelector(`.school-card[data-urn="${urn}"], .school-list-item[data-urn="${urn}"]`);
|
|
if (card) {
|
|
card.classList.add("highlighted");
|
|
card.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
}
|
|
|
|
// Center map on the school and open popup
|
|
if (centerMap && resultsMapInstance && resultsMapMarkers.has(urn)) {
|
|
const { marker, lat, lng } = resultsMapMarkers.get(urn);
|
|
resultsMapInstance.setView([lat, lng], 15, { animate: true });
|
|
marker.openPopup();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Destroy the results map instance
|
|
*/
|
|
function destroyResultsMap() {
|
|
if (resultsMapInstance) {
|
|
try {
|
|
resultsMapInstance.remove();
|
|
} catch (e) {
|
|
// Ignore cleanup errors
|
|
}
|
|
resultsMapInstance = null;
|
|
}
|
|
resultsMapMarkers.clear();
|
|
}
|
|
|
|
/**
|
|
* Update the view toggle visibility and state
|
|
*/
|
|
function updateViewToggle() {
|
|
// Only show toggle for location search results
|
|
if (state.locationSearch.active && state.schools.length > 0) {
|
|
elements.viewToggle.style.display = "flex";
|
|
} else {
|
|
elements.viewToggle.style.display = "none";
|
|
// Reset to list view when hiding toggle
|
|
if (state.resultsView === "map") {
|
|
setResultsView("list");
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set the results view mode (list or map)
|
|
*/
|
|
function setResultsView(view) {
|
|
state.resultsView = view;
|
|
|
|
// Update toggle button states
|
|
elements.viewToggleBtns.forEach((btn) => {
|
|
btn.classList.toggle("active", btn.dataset.view === view);
|
|
});
|
|
|
|
// Update container class and render appropriate view
|
|
if (view === "map") {
|
|
elements.resultsContainer.classList.add("map-view");
|
|
// Render compact list for map view
|
|
if (state.schools.length > 0) {
|
|
renderCompactSchoolList(state.schools);
|
|
}
|
|
// Initialize map if location search is active
|
|
if (state.locationSearch.active) {
|
|
initializeResultsMap(state.schools);
|
|
}
|
|
} else {
|
|
elements.resultsContainer.classList.remove("map-view");
|
|
destroyResultsMap();
|
|
// Re-render full cards for list view
|
|
if (state.schools.length > 0 && state.locationSearch.active) {
|
|
renderSchools(state.schools);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render compact school list items for map view
|
|
*/
|
|
function renderCompactSchoolList(schools) {
|
|
const html = schools
|
|
.map((school) => {
|
|
const distanceBadge =
|
|
school.distance !== undefined && school.distance !== null
|
|
? `<span class="distance-badge">${school.distance.toFixed(1)} mi</span>`
|
|
: "";
|
|
|
|
return `
|
|
<div class="school-list-item" data-urn="${school.urn}">
|
|
<div class="school-list-item-content">
|
|
<div class="school-list-item-header">
|
|
<h4 class="school-list-item-name">${escapeHtml(school.school_name)}</h4>
|
|
${distanceBadge}
|
|
</div>
|
|
<div class="school-list-item-meta">
|
|
<span>${escapeHtml(school.school_type || "")}</span>
|
|
<span>${escapeHtml(school.local_authority || "")}</span>
|
|
</div>
|
|
<div class="school-list-item-stats">
|
|
<span class="school-list-item-stat">
|
|
<strong>${formatMetricValue(school.rwm_expected_pct, "rwm_expected_pct")}</strong> RWM
|
|
</span>
|
|
<span class="school-list-item-stat">
|
|
<strong>${school.total_pupils || "-"}</strong> pupils
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<button class="btn btn-secondary school-list-item-details" data-urn="${school.urn}">Details</button>
|
|
</div>
|
|
`;
|
|
})
|
|
.join("");
|
|
|
|
elements.schoolsGrid.innerHTML = html;
|
|
|
|
// Add click handlers for list items (to highlight on map)
|
|
elements.schoolsGrid.querySelectorAll(".school-list-item").forEach((item) => {
|
|
item.addEventListener("click", (e) => {
|
|
// Don't trigger if clicking the details button
|
|
if (e.target.closest(".school-list-item-details")) return;
|
|
const urn = parseInt(item.dataset.urn, 10);
|
|
highlightSchoolCard(urn, true);
|
|
});
|
|
});
|
|
|
|
// Add click handlers for details buttons
|
|
elements.schoolsGrid.querySelectorAll(".school-list-item-details").forEach((btn) => {
|
|
btn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const urn = parseInt(btn.dataset.urn, 10);
|
|
openSchoolModal(urn);
|
|
});
|
|
});
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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;
|
|
|
|
// Find latest year with progress score data (not available for 2023-24, 2024-25)
|
|
const latestWithProgress = sortedData.find(
|
|
(d) => d.reading_progress !== null || d.writing_progress !== null || d.maths_progress !== null
|
|
);
|
|
const progressYear = latestWithProgress?.year || latest.year;
|
|
const progressData = latestWithProgress || latest;
|
|
|
|
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 (${progressYear}) ${createWarningTrigger("progress_scores_unavailable")}</h4>
|
|
<div class="modal-stats-grid">
|
|
<div class="modal-stat">
|
|
<div class="modal-stat-value ${getProgressClass(progressData.reading_progress)}">${formatMetricValue(progressData.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(progressData.writing_progress)}">${formatMetricValue(progressData.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(progressData.maths_progress)}">${formatMetricValue(progressData.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: "© 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, coords: null };
|
|
elements.postcodeSearch.value = "";
|
|
elements.radiusSelect.value = "5";
|
|
elements.typeFilterLocation.value = "";
|
|
updateLocationInfoBanner(null);
|
|
// Reset to list view and hide toggle
|
|
setResultsView("list");
|
|
updateViewToggle();
|
|
} else {
|
|
// Clear name search state
|
|
elements.schoolSearch.value = "";
|
|
elements.localAuthorityFilter.value = "";
|
|
elements.typeFilter.value = "";
|
|
}
|
|
|
|
// Reload schools (will show featured if no active search)
|
|
loadSchools();
|
|
});
|
|
});
|
|
|
|
// View toggle (list/map)
|
|
elements.viewToggleBtns.forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const view = btn.dataset.view;
|
|
if (view !== state.resultsView) {
|
|
setResultsView(view);
|
|
}
|
|
});
|
|
});
|
|
|
|
// 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;
|