2026-01-06 13:52:00 +00:00
/ * *
2026-01-06 15:39:06 +00:00
* SchoolCompare . co . uk - Frontend Application
* Interactive UK Primary School Performance Comparison
2026-01-06 13:52:00 +00:00
* /
2026-01-06 21:55:59 +00:00
const API _BASE = "" ;
2026-01-06 13:52:00 +00:00
2026-01-06 16:30:32 +00:00
// =============================================================================
// STATE MANAGEMENT
// =============================================================================
const state = {
2026-01-06 21:55:59 +00:00
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
2026-01-07 15:04:30 +00:00
searchMode : "name" , // "name" or "location"
2026-01-06 21:55:59 +00:00
locationSearch : {
active : false ,
postcode : null ,
radius : 5 ,
} ,
loading : {
schools : false ,
filters : false ,
rankings : false ,
comparison : false ,
modal : false ,
} ,
2026-01-06 16:30:32 +00:00
} ;
// Charts
2026-01-06 13:52:00 +00:00
let comparisonChart = null ;
let schoolDetailChart = null ;
2026-01-09 11:52:13 +00:00
let modalMap = null ;
2026-01-06 13:52:00 +00:00
// Chart colors
const CHART _COLORS = [
2026-01-06 21:55:59 +00:00
"#e07256" , // coral
"#2d7d7d" , // teal
"#c9a227" , // gold
"#7b68a6" , // purple
"#3498db" , // blue
"#27ae60" , // green
"#e74c3c" , // red
"#9b59b6" , // violet
2026-01-06 13:52:00 +00:00
] ;
2026-01-09 15:10:39 +00:00
// 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 ,
} ,
} ;
2026-01-13 15:12:11 +00:00
// 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." ,
} ,
} ;
2026-01-09 15:10:39 +00:00
/ * *
* 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> ` ;
}
2026-01-13 15:12:11 +00:00
/ * *
* 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> ` ;
}
2026-01-08 23:20:42 +00:00
// Map instances (stored to allow cleanup)
const schoolMaps = new Map ( ) ;
2026-01-07 15:32:22 +00:00
// Helper to get chart aspect ratio based on screen size
function getChartAspectRatio ( ) {
2026-01-14 19:09:07 +00:00
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 ;
2026-01-07 15:32:22 +00:00
}
2026-01-06 16:30:32 +00:00
// =============================================================================
// DOM ELEMENTS
// =============================================================================
2026-01-06 13:52:00 +00:00
const elements = {
2026-01-07 15:04:30 +00:00
// 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
2026-01-06 21:55:59 +00:00
schoolSearch : document . getElementById ( "school-search" ) ,
localAuthorityFilter : document . getElementById ( "local-authority-filter" ) ,
typeFilter : document . getElementById ( "type-filter" ) ,
2026-01-07 15:04:30 +00:00
// Location search
2026-01-06 21:55:59 +00:00
postcodeSearch : document . getElementById ( "postcode-search" ) ,
radiusSelect : document . getElementById ( "radius-select" ) ,
locationSearchBtn : document . getElementById ( "location-search-btn" ) ,
2026-01-07 15:04:30 +00:00
typeFilterLocation : document . getElementById ( "type-filter-location" ) ,
// Schools grid
2026-01-06 21:55:59 +00:00
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" ) ,
2026-01-09 11:52:13 +00:00
modalDetails : document . getElementById ( "modal-details" ) ,
2026-01-06 21:55:59 +00:00
modalStats : document . getElementById ( "modal-stats" ) ,
2026-01-09 11:52:13 +00:00
modalMapContainer : document . getElementById ( "modal-map-container" ) ,
modalMap : document . getElementById ( "modal-map" ) ,
2026-01-06 21:55:59 +00:00
schoolDetailChart : document . getElementById ( "school-detail-chart" ) ,
addToCompare : document . getElementById ( "add-to-compare" ) ,
2026-01-06 13:52:00 +00:00
} ;
2026-01-06 16:30:32 +00:00
// =============================================================================
// API & CACHING
// =============================================================================
2026-01-06 13:52:00 +00:00
2026-01-06 16:30:32 +00:00
const apiCache = new Map ( ) ;
const CACHE _TTL = 5 * 60 * 1000 ; // 5 minutes
2026-01-06 13:52:00 +00:00
2026-01-06 16:30:32 +00:00
async function fetchAPI ( endpoint , options = { } ) {
2026-01-06 21:55:59 +00:00
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 ;
2026-01-06 16:30:32 +00:00
}
2026-01-06 21:55:59 +00:00
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 ( ) } ) ;
2026-01-06 16:30:32 +00:00
}
2026-01-06 21:55:59 +00:00
return data ;
} catch ( error ) {
console . error ( ` API Error ( ${ endpoint } ): ` , error ) ;
return null ;
} finally {
if ( showLoading ) {
state . loading [ showLoading ] = false ;
updateLoadingUI ( showLoading ) ;
2026-01-06 13:52:00 +00:00
}
2026-01-06 21:55:59 +00:00
}
2026-01-06 13:52:00 +00:00
}
2026-01-06 16:30:32 +00:00
function updateLoadingUI ( section ) {
2026-01-06 21:55:59 +00:00
// 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 ;
}
2026-01-06 16:30:32 +00:00
}
2026-01-06 21:55:59 +00:00
function renderLoadingSkeleton ( count , type = "card" ) {
if ( type === "ranking" ) {
return Array ( count )
. fill ( 0 )
. map (
( ) => `
2026-01-06 16:30:32 +00:00
< div class = "ranking-item skeleton" >
< div class = "skeleton-circle" > < / d i v >
< div class = "skeleton-content" >
< div class = "skeleton-line" style = "width: 60%" > < / d i v >
< div class = "skeleton-line short" style = "width: 30%" > < / d i v >
< / d i v >
< div class = "skeleton-score" > < / d i v >
< / d i v >
2026-01-06 21:55:59 +00:00
` ,
)
. join ( "" ) ;
}
return Array ( count )
. fill ( 0 )
. map (
( ) => `
2026-01-06 16:30:32 +00:00
< div class = "school-card skeleton" >
< div class = "skeleton-line" style = "width: 70%" > < / d i v >
< div class = "skeleton-line short" style = "width: 40%" > < / d i v >
< div class = "skeleton-line" style = "width: 90%" > < / d i v >
< / d i v >
2026-01-06 21:55:59 +00:00
` ,
)
. join ( "" ) ;
2026-01-06 16:30:32 +00:00
}
2026-01-06 16:59:25 +00:00
// =============================================================================
// ROUTING
// =============================================================================
const routes = {
2026-01-08 22:59:55 +00:00
"/" : "home" ,
2026-01-06 21:55:59 +00:00
"/compare" : "compare" ,
"/rankings" : "rankings" ,
2026-01-06 16:59:25 +00:00
} ;
2026-01-08 11:47:38 +00:00
const pageTitles = {
2026-01-08 22:59:55 +00:00
home : "SchoolCompare | Compare Primary School Performance" ,
2026-01-08 11:47:38 +00:00
compare : "Compare Schools | SchoolCompare" ,
rankings : "School Rankings | SchoolCompare" ,
} ;
2026-01-06 16:59:25 +00:00
function navigateTo ( path ) {
2026-01-06 21:55:59 +00:00
// Update URL without reload
window . history . pushState ( { } , "" , path ) ;
handleRoute ( ) ;
2026-01-06 16:59:25 +00:00
}
function handleRoute ( ) {
2026-01-08 23:48:56 +00:00
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 = "/" ;
}
2026-01-08 22:59:55 +00:00
const view = routes [ path ] || "home" ;
2026-01-06 21:55:59 +00:00
2026-01-08 11:47:38 +00:00
// Update page title for SEO
2026-01-08 22:59:55 +00:00
document . title = pageTitles [ view ] || pageTitles . home ;
2026-01-08 11:47:38 +00:00
2026-01-06 21:55:59 +00:00
// 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" ) ;
}
2026-01-06 16:59:25 +00:00
}
2026-01-06 16:30:32 +00:00
// =============================================================================
// INITIALIZATION
// =============================================================================
2026-01-06 21:55:59 +00:00
document . addEventListener ( "DOMContentLoaded" , init ) ;
2026-01-06 16:30:32 +00:00
async function init ( ) {
2026-01-12 15:55:23 +00:00
// Load selected schools from localStorage first
loadSelectedSchoolsFromStorage ( ) ;
2026-01-08 23:48:56 +00:00
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 ) ;
}
2026-01-06 21:55:59 +00:00
2026-01-08 23:48:56 +00:00
// Cache metrics
if ( metricsData ) {
state . metrics = metricsData . metrics ;
}
2026-01-06 21:55:59 +00:00
2026-01-08 23:48:56 +00:00
// Load initial data
await loadSchools ( ) ;
await loadRankings ( ) ;
} catch ( err ) {
console . error ( "Error during initialization:" , err ) ;
}
2026-01-06 21:55:59 +00:00
2026-01-08 23:48:56 +00:00
// Always set up event listeners and routing, even if data loading fails
2026-01-06 21:55:59 +00:00
setupEventListeners ( ) ;
2026-01-09 15:10:39 +00:00
// Initialize tooltip manager
tooltipManager = new TooltipManager ( ) ;
2026-01-12 15:55:23 +00:00
// Render any previously selected schools
renderSelectedSchools ( ) ;
2026-01-06 21:55:59 +00:00
// Handle initial route
handleRoute ( ) ;
// Handle browser back/forward
window . addEventListener ( "popstate" , handleRoute ) ;
2026-01-06 16:30:32 +00:00
}
2026-01-06 13:52:00 +00:00
2026-01-06 16:30:32 +00:00
function populateFilters ( data ) {
2026-01-08 22:59:55 +00:00
// Populate local authority filter (home page)
2026-01-06 21:55:59 +00:00
data . local _authorities . forEach ( ( la ) => {
const option = document . createElement ( "option" ) ;
option . value = la ;
option . textContent = la ;
elements . localAuthorityFilter . appendChild ( option ) ;
} ) ;
2026-01-06 16:17:00 +00:00
2026-01-07 15:04:30 +00:00
// Populate school type filter (both name and location panels)
2026-01-06 21:55:59 +00:00
data . school _types . forEach ( ( type ) => {
const option = document . createElement ( "option" ) ;
option . value = type ;
option . textContent = type ;
elements . typeFilter . appendChild ( option ) ;
2026-01-07 15:04:30 +00:00
// Also add to location panel's type filter
const optionLocation = document . createElement ( "option" ) ;
optionLocation . value = type ;
optionLocation . textContent = type ;
elements . typeFilterLocation . appendChild ( optionLocation ) ;
2026-01-06 21:55:59 +00:00
} ) ;
// 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 ) ;
2026-01-06 16:30:32 +00:00
} ) ;
2026-01-06 13:52:00 +00:00
}
2026-01-06 16:30:32 +00:00
// =============================================================================
// DATA LOADING
// =============================================================================
2026-01-06 13:52:00 +00:00
async function loadSchools ( ) {
2026-01-06 21:55:59 +00:00
const params = new URLSearchParams ( ) ;
2026-01-07 15:04:30 +00:00
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 ;
}
2026-01-06 21:55:59 +00:00
params . append ( "postcode" , postcode ) ;
params . append ( "radius" , radius ) ;
2026-01-07 15:04:30 +00:00
if ( type ) params . append ( "school_type" , type ) ;
2026-01-06 21:55:59 +00:00
}
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 ) ;
2026-01-06 13:52:00 +00:00
}
2026-01-06 16:30:32 +00:00
async function loadFeaturedSchools ( ) {
2026-01-06 21:55:59 +00:00
// 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 ) ;
2026-01-06 13:52:00 +00:00
}
2026-01-06 16:59:25 +00:00
function updateLocationInfoBanner ( searchLocation ) {
2026-01-06 21:55:59 +00:00
// 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 = `
2026-01-06 16:59:25 +00:00
< 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" / >
< / s v g >
< span > Showing schools within $ { searchLocation . radius } miles of < strong > $ { searchLocation . postcode . toUpperCase ( ) } < / s t r o n g > < / s p a n >
` ;
2026-01-06 21:55:59 +00:00
// Insert banner before the schools grid
elements . schoolsGrid . parentNode . insertBefore ( banner , elements . schoolsGrid ) ;
2026-01-06 16:59:25 +00:00
}
async function searchByLocation ( ) {
2026-01-06 21:55:59 +00:00
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 ( ) ;
2026-01-06 16:59:25 +00:00
}
2026-01-06 16:30:32 +00:00
function renderFeaturedSchools ( schools ) {
2026-01-06 21:55:59 +00:00
elements . schoolsGrid . innerHTML = `
2026-01-06 16:30:32 +00:00
< div class = "featured-header" >
< h3 > Featured Schools < / h 3 >
2026-01-06 21:55:59 +00:00
< p > Start typing to search schools across England < / p >
2026-01-06 16:30:32 +00:00
< / d i v >
2026-01-06 21:55:59 +00:00
$ { schools
2026-01-08 23:20:42 +00:00
. 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 `
2026-01-06 16:30:32 +00:00
< div class = "school-card featured" data - urn = "${school.urn}" >
< h3 class = "school-name" > $ { escapeHtml ( school . school _name ) } < / h 3 >
< div class = "school-meta" >
2026-01-06 21:55:59 +00:00
< span class = "school-tag" > $ { escapeHtml ( school . local _authority || "" ) } < / s p a n >
< span class = "school-tag type" > $ { escapeHtml ( school . school _type || "" ) } < / s p a n >
2026-01-08 23:20:42 +00:00
$ { faithTag }
2026-01-06 16:30:32 +00:00
< / d i v >
2026-01-06 21:55:59 +00:00
< div class = "school-address" > $ { escapeHtml ( school . address || "" ) } < / d i v >
2026-01-08 23:20:42 +00:00
$ { ageRange ? ` <div class="school-details"> ${ ageRange } </div> ` : "" }
2026-01-06 16:30:32 +00:00
< div class = "school-stats" >
< div class = "stat" >
2026-01-09 14:36:01 +00:00
< div class = "stat-value" >
$ { formatMetricValue ( school . rwm _expected _pct , "rwm_expected_pct" ) }
$ { getTrendIndicator ( school . rwm _expected _pct , school . prev _rwm _expected _pct ) }
< / d i v >
2026-01-09 15:10:39 +00:00
< div class = "stat-label" > < span class = "stat-label-with-info" > RWM Expected$ { createInfoTrigger ( "rwm_expected" ) } < / s p a n > < / d i v >
2026-01-06 16:30:32 +00:00
< / d i v >
2026-01-09 14:36:01 +00:00
< div class = "stat" >
< div class = "stat-value" > $ { formatMetricValue ( school . rwm _high _pct , "rwm_high_pct" ) } < / d i v >
2026-01-09 15:10:39 +00:00
< div class = "stat-label" > < span class = "stat-label-with-info" > RWM Higher$ { createInfoTrigger ( "rwm_higher" ) } < / s p a n > < / d i v >
2026-01-09 14:36:01 +00:00
< / d i v >
2026-01-06 16:30:32 +00:00
< div class = "stat" >
2026-01-06 22:06:59 +00:00
< div class = "stat-value" > $ { school . total _pupils || "-" } < / d i v >
2026-01-09 15:10:39 +00:00
< div class = "stat-label" > < span class = "stat-label-with-info" > Pupils$ { createInfoTrigger ( "total_pupils" ) } < / s p a n > < / d i v >
2026-01-06 16:30:32 +00:00
< / d i v >
< / d i v >
2026-01-08 23:20:42 +00:00
$ { mapContainer }
2026-01-06 16:30:32 +00:00
< / d i v >
2026-01-08 23:20:42 +00:00
` ;
} )
2026-01-06 21:55:59 +00:00
. join ( "" ) }
2026-01-06 16:30:32 +00:00
` ;
2026-01-06 21:55:59 +00:00
2026-01-08 23:20:42 +00:00
// Initialize maps
initializeSchoolMaps ( elements . schoolsGrid ) ;
2026-01-06 21:55:59 +00:00
// Add click handlers
elements . schoolsGrid . querySelectorAll ( ".school-card" ) . forEach ( ( card ) => {
2026-01-08 23:20:42 +00:00
card . addEventListener ( "click" , ( e ) => {
2026-01-09 15:10:39 +00:00
// Don't trigger if clicking on map or info trigger
if ( e . target . closest ( ".school-map" ) || e . target . closest ( ".info-trigger" ) ) return ;
2026-01-06 21:55:59 +00:00
const urn = parseInt ( card . dataset . urn ) ;
openSchoolModal ( urn ) ;
2026-01-06 16:30:32 +00:00
} ) ;
2026-01-06 21:55:59 +00:00
} ) ;
2026-01-06 16:30:32 +00:00
}
async function loadSchoolDetails ( urn ) {
2026-01-06 21:55:59 +00:00
const data = await fetchAPI ( ` /api/schools/ ${ urn } ` , { showLoading : "modal" } ) ;
return data ;
2026-01-06 13:52:00 +00:00
}
2026-01-06 16:30:32 +00:00
async function loadComparison ( ) {
2026-01-06 21:55:59 +00:00
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 ;
2026-01-06 13:52:00 +00:00
}
async function loadRankings ( ) {
2026-01-06 21:55:59 +00:00
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 ) ;
2026-01-06 13:52:00 +00:00
}
2026-01-06 16:30:32 +00:00
// =============================================================================
// METRIC HELPERS
// =============================================================================
function getMetricLabel ( key , short = false ) {
2026-01-06 21:55:59 +00:00
if ( state . metrics ) {
const metric = state . metrics . find ( ( m ) => m . key === key ) ;
if ( metric ) {
return short ? metric . short _name : metric . name ;
2026-01-06 16:30:32 +00:00
}
2026-01-06 21:55:59 +00:00
}
// Fallback labels
return key . replace ( /_/g , " " ) . replace ( /\b\w/g , ( l ) => l . toUpperCase ( ) ) ;
2026-01-06 16:30:32 +00:00
}
function formatMetricValue ( value , metric ) {
2026-01-06 21:55:59 +00:00
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 ) ;
2026-01-06 16:30:32 +00:00
}
2026-01-09 14:36:01 +00:00
/ * *
* 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> ` ;
}
}
2026-01-08 23:20:42 +00:00
// =============================================================================
// MAP FUNCTIONS
// =============================================================================
/ * *
* Initialize Leaflet maps for all school cards in a container
* /
function initializeSchoolMaps ( container ) {
2026-01-08 23:29:48 +00:00
// 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 ;
2026-01-08 23:20:42 +00:00
// Clean up existing maps first
2026-01-08 23:29:48 +00:00
mapElements . forEach ( ( mapEl ) => {
2026-01-08 23:20:42 +00:00
const existingMap = schoolMaps . get ( mapEl ) ;
if ( existingMap ) {
2026-01-08 23:29:48 +00:00
try {
existingMap . remove ( ) ;
} catch ( e ) {
// Ignore cleanup errors
}
2026-01-08 23:20:42 +00:00
schoolMaps . delete ( mapEl ) ;
}
} ) ;
2026-01-08 23:29:48 +00:00
// 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 ) ;
}
2026-01-08 23:20:42 +00:00
} ) ;
2026-01-08 23:29:48 +00:00
} , 100 ) ;
2026-01-08 23:20:42 +00:00
}
/ * *
* 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 ) } < / h 3 >
< button class = "map-modal-close" aria - label = "Close map" > & times ; < / b u t t o n >
< / d i v >
< div class = "map-modal-content" id = "fullscreen-map" > < / d i v >
< / d i v >
` ;
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 ) ;
}
2026-01-06 16:30:32 +00:00
// =============================================================================
// RENDER FUNCTIONS
// =============================================================================
2026-01-06 13:52:00 +00:00
function renderSchools ( schools ) {
2026-01-06 21:55:59 +00:00
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> `
: "" ;
2026-01-08 23:20:42 +00:00
// 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> `
: "" ;
2026-01-06 21:55:59 +00:00
return `
2026-01-06 16:59:25 +00:00
< div class = "school-card" data - urn = "${school.urn}" >
< h3 class = "school-name" > $ { escapeHtml ( school . school _name ) } $ { distanceBadge } < / h 3 >
< div class = "school-meta" >
2026-01-06 21:55:59 +00:00
< span class = "school-tag" > $ { escapeHtml ( school . local _authority || "" ) } < / s p a n >
< span class = "school-tag type" > $ { escapeHtml ( school . school _type || "" ) } < / s p a n >
2026-01-08 23:20:42 +00:00
$ { faithTag }
2026-01-06 13:52:00 +00:00
< / d i v >
2026-01-06 21:55:59 +00:00
< div class = "school-address" > $ { escapeHtml ( school . address || "" ) } < / d i v >
2026-01-08 23:20:42 +00:00
$ { ageRange ? ` <div class="school-details"> ${ ageRange } </div> ` : "" }
2026-01-06 16:59:25 +00:00
< div class = "school-stats" >
< div class = "stat" >
2026-01-09 14:36:01 +00:00
< div class = "stat-value" >
$ { formatMetricValue ( school . rwm _expected _pct , "rwm_expected_pct" ) }
$ { getTrendIndicator ( school . rwm _expected _pct , school . prev _rwm _expected _pct ) }
< / d i v >
2026-01-09 15:10:39 +00:00
< div class = "stat-label" > < span class = "stat-label-with-info" > RWM Expected$ { createInfoTrigger ( "rwm_expected" ) } < / s p a n > < / d i v >
2026-01-06 16:59:25 +00:00
< / d i v >
2026-01-09 14:36:01 +00:00
< div class = "stat" >
< div class = "stat-value" > $ { formatMetricValue ( school . rwm _high _pct , "rwm_high_pct" ) } < / d i v >
2026-01-09 15:10:39 +00:00
< div class = "stat-label" > < span class = "stat-label-with-info" > RWM Higher$ { createInfoTrigger ( "rwm_higher" ) } < / s p a n > < / d i v >
2026-01-09 14:36:01 +00:00
< / d i v >
2026-01-06 16:59:25 +00:00
< div class = "stat" >
2026-01-06 22:06:59 +00:00
< div class = "stat-value" > $ { school . total _pupils || "-" } < / d i v >
2026-01-09 15:10:39 +00:00
< div class = "stat-label" > < span class = "stat-label-with-info" > Pupils$ { createInfoTrigger ( "total_pupils" ) } < / s p a n > < / d i v >
2026-01-06 16:59:25 +00:00
< / d i v >
2026-01-06 13:52:00 +00:00
< / d i v >
2026-01-08 23:20:42 +00:00
$ { mapContainer }
2026-01-06 13:52:00 +00:00
< / d i v >
2026-01-06 16:59:25 +00:00
` ;
2026-01-06 21:55:59 +00:00
} )
. join ( "" ) ;
// Add pagination info
if ( state . pagination . totalPages > 1 ) {
html += `
2026-01-06 16:30:32 +00:00
< div class = "pagination-info" >
< span > Showing $ { schools . length } of $ { state . pagination . total } schools < / s p a n >
2026-01-06 21:55:59 +00:00
$ {
state . pagination . page < state . pagination . totalPages
? ` <button class="btn-load-more" onclick="loadMoreSchools()">Load More</button> `
: ""
}
2026-01-06 16:30:32 +00:00
< / d i v >
` ;
2026-01-06 21:55:59 +00:00
}
elements . schoolsGrid . innerHTML = html ;
2026-01-08 23:20:42 +00:00
// Initialize maps
initializeSchoolMaps ( elements . schoolsGrid ) ;
2026-01-06 21:55:59 +00:00
// Add click handlers
elements . schoolsGrid . querySelectorAll ( ".school-card" ) . forEach ( ( card ) => {
2026-01-08 23:20:42 +00:00
card . addEventListener ( "click" , ( e ) => {
2026-01-09 15:10:39 +00:00
// Don't trigger if clicking on map or info trigger
if ( e . target . closest ( ".school-map" ) || e . target . closest ( ".info-trigger" ) ) return ;
2026-01-06 21:55:59 +00:00
const urn = parseInt ( card . dataset . urn ) ;
openSchoolModal ( urn ) ;
2026-01-06 13:52:00 +00:00
} ) ;
2026-01-06 21:55:59 +00:00
} ) ;
2026-01-06 13:52:00 +00:00
}
2026-01-06 16:30:32 +00:00
async function loadMoreSchools ( ) {
2026-01-06 21:55:59 +00:00
state . pagination . page ++ ;
const params = new URLSearchParams ( ) ;
2026-01-07 15:04:30 +00:00
if ( state . searchMode === "name" ) {
const search = elements . schoolSearch . value . trim ( ) ;
if ( search ) params . append ( "search" , search ) ;
2026-01-06 21:55:59 +00:00
2026-01-07 15:04:30 +00:00
const localAuthority = elements . localAuthorityFilter . value ;
if ( localAuthority ) params . append ( "local_authority" , localAuthority ) ;
2026-01-06 21:55:59 +00:00
2026-01-07 15:04:30 +00:00
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 ) ;
}
2026-01-06 21:55:59 +00:00
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 ) ;
}
2026-01-06 16:30:32 +00:00
}
2026-01-06 13:52:00 +00:00
function renderRankings ( rankings , metric ) {
2026-01-06 21:55:59 +00:00
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 `
2026-01-06 13:52:00 +00:00
< div class = "ranking-item" data - urn = "${school.urn}" >
2026-01-06 21:55:59 +00:00
< div class = "ranking-position ${index < 3 ? " top - 3 " : " "}" > $ { index + 1 } < / d i v >
2026-01-06 13:52:00 +00:00
< div class = "ranking-info" >
< div class = "ranking-name" > $ { escapeHtml ( school . school _name ) } < / d i v >
2026-01-06 21:55:59 +00:00
< div class = "ranking-location" > $ { escapeHtml ( school . local _authority || "" ) } < / d i v >
2026-01-06 13:52:00 +00:00
< / d i v >
< div class = "ranking-score" >
2026-01-06 16:30:32 +00:00
< div class = "ranking-score-value" > $ { formatMetricValue ( value , metric ) } < / d i v >
< div class = "ranking-score-label" > $ { getMetricLabel ( metric , true ) } < / d i v >
2026-01-06 13:52:00 +00:00
< / d i v >
< / d i v >
` ;
2026-01-06 21:55:59 +00:00
} )
. filter ( Boolean )
. join ( "" ) ;
// Add click handlers
elements . rankingsList . querySelectorAll ( ".ranking-item" ) . forEach ( ( item ) => {
item . addEventListener ( "click" , ( ) => {
const urn = parseInt ( item . dataset . urn ) ;
openSchoolModal ( urn ) ;
2026-01-06 13:52:00 +00:00
} ) ;
2026-01-06 21:55:59 +00:00
} ) ;
2026-01-06 13:52:00 +00:00
}
function renderSelectedSchools ( ) {
2026-01-06 21:55:59 +00:00
if ( state . selectedSchools . length === 0 ) {
elements . selectedSchools . innerHTML = `
2026-01-06 13:52:00 +00:00
< 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" / >
< / s v g >
< / d i v >
< p > Search and add schools to compare < / p >
< / d i v >
` ;
2026-01-06 21:55:59 +00:00
elements . chartsSection . style . display = "none" ;
return ;
}
elements . selectedSchools . innerHTML = state . selectedSchools
. map (
( school , index ) => `
2026-01-06 13:52:00 +00:00
< div class = "selected-school-tag" style = "border-left: 3px solid ${CHART_COLORS[index % CHART_COLORS.length]}" >
< span > $ { escapeHtml ( school . school _name ) } < / s p a n >
< 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" / >
< / s v g >
< / b u t t o n >
< / d i v >
2026-01-06 21:55:59 +00:00
` ,
)
. join ( "" ) ;
// Add remove handlers
elements . selectedSchools . querySelectorAll ( ".remove" ) . forEach ( ( btn ) => {
btn . addEventListener ( "click" , ( e ) => {
e . stopPropagation ( ) ;
const urn = parseInt ( btn . dataset . urn ) ;
removeFromComparison ( urn ) ;
2026-01-06 13:52:00 +00:00
} ) ;
2026-01-06 21:55:59 +00:00
} ) ;
elements . chartsSection . style . display = "block" ;
updateComparisonChart ( ) ;
2026-01-06 13:52:00 +00:00
}
async function updateComparisonChart ( ) {
2026-01-06 21:55:59 +00:00
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 ,
2026-01-06 13:52:00 +00:00
} ) ;
2026-01-06 21:55:59 +00:00
} ) ;
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 ,
2026-01-07 15:32:22 +00:00
aspectRatio : getChartAspectRatio ( ) ,
2026-01-06 21:55:59 +00:00
plugins : {
legend : {
position : "bottom" ,
labels : {
font : { family : "'DM Sans', sans-serif" , size : 12 } ,
padding : 20 ,
usePointStyle : true ,
} ,
2026-01-06 13:52:00 +00:00
} ,
2026-01-06 21:55:59 +00:00
title : {
display : true ,
text : getMetricLabel ( metric ) ,
font : { family : "'Playfair Display', serif" , size : 18 , weight : 600 } ,
padding : { bottom : 20 } ,
2026-01-06 13:52:00 +00:00
} ,
2026-01-06 21:55:59 +00:00
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 ) ;
2026-01-06 13:52:00 +00:00
}
function updateComparisonTable ( comparison , metric , years ) {
2026-01-06 21:55:59 +00:00
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> ` ;
2026-01-06 13:52:00 +00:00
} ) ;
2026-01-06 15:49:57 +00:00
if ( prevYear ) {
2026-01-06 21:55:59 +00:00
bodyHtml += ` <td class=" ${ oneYearClass } "> ${ oneYearChangeStr !== "N/A" ? ( oneYearChange >= 0 ? "+" : "" ) + oneYearChangeStr : oneYearChangeStr } </td> ` ;
2026-01-06 15:37:07 +00:00
}
2026-01-06 15:49:57 +00:00
if ( years . length > 2 ) {
2026-01-06 21:55:59 +00:00
bodyHtml += ` <td> ${ variabilityStr } </td> ` ;
2026-01-06 15:37:07 +00:00
}
2026-01-06 21:55:59 +00:00
bodyHtml += ` </tr> ` ;
} ) ;
elements . tableBody . innerHTML = bodyHtml ;
2026-01-06 13:52:00 +00:00
}
async function openSchoolModal ( urn ) {
2026-01-09 08:42:38 +00:00
// Hide all school maps to prevent z-index overlap with modal
document . querySelectorAll ( '.school-map' ) . forEach ( el => {
el . style . visibility = 'hidden' ;
} ) ;
2026-01-06 21:55:59 +00:00
// 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 ;
2026-01-09 11:52:13 +00:00
// 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> `
: "" ;
2026-01-06 21:55:59 +00:00
elements . modalMeta . innerHTML = `
< span class = "school-tag" > $ { escapeHtml ( data . school _info . local _authority || "" ) } < / s p a n >
< span class = "school-tag type" > $ { escapeHtml ( data . school _info . school _type || "" ) } < / s p a n >
2026-01-09 11:52:13 +00:00
$ { faithTag }
2026-01-06 13:52:00 +00:00
` ;
2026-01-06 21:55:59 +00:00
2026-01-09 11:52:13 +00:00
// 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 ;
2026-01-06 21:55:59 +00:00
// 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 ] ;
2026-01-09 14:36:01 +00:00
// Get previous year for trend calculation
const latestIndex = sortedData . indexOf ( latest ) ;
const previous = sortedData [ latestIndex + 1 ] || null ;
const prevRwm = previous ? . rwm _expected _pct ;
2026-01-06 21:55:59 +00:00
elements . modalStats . innerHTML = `
2026-01-06 13:52:00 +00:00
< div class = "modal-stats-section" >
< h4 > KS2 Results ( $ { latest . year } ) < / h 4 >
< div class = "modal-stats-grid" >
< div class = "modal-stat" >
2026-01-09 14:36:01 +00:00
< div class = "modal-stat-value" > $ { formatMetricValue ( latest . rwm _expected _pct , "rwm_expected_pct" ) } $ { getTrendIndicator ( latest . rwm _expected _pct , prevRwm ) } < / d i v >
2026-01-09 15:10:39 +00:00
< div class = "modal-stat-label" > RWM Expected$ { createInfoTrigger ( "rwm_expected" ) } < / d i v >
2026-01-06 13:52:00 +00:00
< / d i v >
< div class = "modal-stat" >
2026-01-06 21:55:59 +00:00
< div class = "modal-stat-value" > $ { formatMetricValue ( latest . rwm _high _pct , "rwm_high_pct" ) } < / d i v >
2026-01-09 15:10:39 +00:00
< div class = "modal-stat-label" > RWM Higher$ { createInfoTrigger ( "rwm_higher" ) } < / d i v >
2026-01-06 13:52:00 +00:00
< / d i v >
< div class = "modal-stat" >
2026-01-06 21:55:59 +00:00
< div class = "modal-stat-value" > $ { formatMetricValue ( latest . gps _expected _pct , "gps_expected_pct" ) } < / d i v >
2026-01-09 15:10:39 +00:00
< div class = "modal-stat-label" > GPS Expected$ { createInfoTrigger ( "gps_expected" ) } < / d i v >
2026-01-06 13:52:00 +00:00
< / d i v >
< div class = "modal-stat" >
2026-01-06 21:55:59 +00:00
< div class = "modal-stat-value" > $ { formatMetricValue ( latest . science _expected _pct , "science_expected_pct" ) } < / d i v >
2026-01-09 15:10:39 +00:00
< div class = "modal-stat-label" > Science Expected$ { createInfoTrigger ( "science_expected" ) } < / d i v >
2026-01-06 13:52:00 +00:00
< / d i v >
< / d i v >
< / d i v >
< div class = "modal-stats-section" >
2026-01-15 09:56:45 +00:00
< h4 > Progress Scores ( $ { latest . year } ) $ { createWarningTrigger ( "progress_scores_unavailable" ) } < / h 4 >
2026-01-06 13:52:00 +00:00
< div class = "modal-stats-grid" >
< div class = "modal-stat" >
2026-01-06 21:55:59 +00:00
< div class = "modal-stat-value ${getProgressClass(latest.reading_progress)}" > $ { formatMetricValue ( latest . reading _progress , "reading_progress" ) } < / d i v >
2026-01-09 15:10:39 +00:00
< div class = "modal-stat-label" > Reading$ { createInfoTrigger ( "reading_progress" ) } < / d i v >
2026-01-06 13:52:00 +00:00
< / d i v >
< div class = "modal-stat" >
2026-01-06 21:55:59 +00:00
< div class = "modal-stat-value ${getProgressClass(latest.writing_progress)}" > $ { formatMetricValue ( latest . writing _progress , "writing_progress" ) } < / d i v >
2026-01-09 15:10:39 +00:00
< div class = "modal-stat-label" > Writing$ { createInfoTrigger ( "writing_progress" ) } < / d i v >
2026-01-06 13:52:00 +00:00
< / d i v >
< div class = "modal-stat" >
2026-01-06 21:55:59 +00:00
< div class = "modal-stat-value ${getProgressClass(latest.maths_progress)}" > $ { formatMetricValue ( latest . maths _progress , "maths_progress" ) } < / d i v >
2026-01-09 15:10:39 +00:00
< div class = "modal-stat-label" > Maths$ { createInfoTrigger ( "maths_progress" ) } < / d i v >
2026-01-06 13:52:00 +00:00
< / d i v >
< / d i v >
< / d i v >
< div class = "modal-stats-section" >
2026-01-15 09:56:45 +00:00
< h4 > School Context ( $ { latest . year } ) < / h 4 >
2026-01-06 13:52:00 +00:00
< div class = "modal-stats-grid" >
< div class = "modal-stat" >
2026-01-06 21:55:59 +00:00
< div class = "modal-stat-value" > $ { latest . total _pupils || "-" } < / d i v >
2026-01-09 15:10:39 +00:00
< div class = "modal-stat-label" > Total Pupils$ { createInfoTrigger ( "total_pupils" ) } < / d i v >
2026-01-06 13:52:00 +00:00
< / d i v >
< div class = "modal-stat" >
2026-01-06 21:55:59 +00:00
< div class = "modal-stat-value" > $ { formatMetricValue ( latest . eal _pct , "eal_pct" ) } < / d i v >
2026-01-09 15:10:39 +00:00
< div class = "modal-stat-label" > % EAL$ { createInfoTrigger ( "eal_pct" ) } < / d i v >
2026-01-06 13:52:00 +00:00
< / d i v >
< div class = "modal-stat" >
2026-01-06 21:55:59 +00:00
< div class = "modal-stat-value" > $ { formatMetricValue ( latest . sen _support _pct , "sen_support_pct" ) } < / d i v >
2026-01-09 15:10:39 +00:00
< div class = "modal-stat-label" > % SEN Support$ { createInfoTrigger ( "sen_support_pct" ) } < / d i v >
2026-01-06 13:52:00 +00:00
< / d i v >
< / d i v >
< / d i v >
` ;
2026-01-06 21:55:59 +00:00
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" ) ;
2026-01-14 19:09:07 +00:00
const mobile = isMobile ( ) ;
2026-01-06 21:55:59 +00:00
schoolDetailChart = new Chart ( ctx , {
type : "bar" ,
data : {
labels : years ,
datasets : [
{
2026-01-14 19:09:07 +00:00
label : mobile ? "Reading" : "Reading %" ,
2026-01-06 21:55:59 +00:00
data : validData . map ( ( d ) => d . reading _expected _pct ) ,
backgroundColor : "#2d7d7d" ,
borderRadius : 4 ,
2026-01-06 13:52:00 +00:00
} ,
2026-01-06 21:55:59 +00:00
{
2026-01-14 19:09:07 +00:00
label : mobile ? "Writing" : "Writing %" ,
2026-01-06 21:55:59 +00:00
data : validData . map ( ( d ) => d . writing _expected _pct ) ,
backgroundColor : "#c9a227" ,
borderRadius : 4 ,
2026-01-06 13:52:00 +00:00
} ,
2026-01-06 21:55:59 +00:00
{
2026-01-14 19:09:07 +00:00
label : mobile ? "Maths" : "Maths %" ,
2026-01-06 21:55:59 +00:00
data : validData . map ( ( d ) => d . maths _expected _pct ) ,
backgroundColor : "#e07256" ,
borderRadius : 4 ,
} ,
] ,
} ,
options : {
responsive : true ,
maintainAspectRatio : true ,
2026-01-07 15:32:22 +00:00
aspectRatio : getChartAspectRatio ( ) ,
2026-01-06 21:55:59 +00:00
plugins : {
legend : {
position : "bottom" ,
labels : {
2026-01-14 19:09:07 +00:00
font : {
family : "'DM Sans', sans-serif" ,
size : mobile ? 11 : 12 ,
} ,
2026-01-06 21:55:59 +00:00
usePointStyle : true ,
2026-01-14 19:09:07 +00:00
pointStyle : "circle" ,
padding : mobile ? 12 : 16 ,
boxWidth : mobile ? 8 : 12 ,
2026-01-06 21:55:59 +00:00
} ,
} ,
title : {
display : true ,
2026-01-14 19:09:07 +00:00
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 } % ` ;
}
}
2026-01-06 21:55:59 +00:00
} ,
} ,
scales : {
y : {
beginAtZero : true ,
max : 100 ,
grid : { color : "#e5dfd5" } ,
2026-01-14 19:09:07 +00:00
ticks : {
font : { size : mobile ? 10 : 12 } ,
callback : function ( value ) {
return value + "%" ;
} ,
} ,
2026-01-06 21:55:59 +00:00
} ,
x : {
grid : { display : false } ,
2026-01-14 19:09:07 +00:00
ticks : {
font : { size : mobile ? 10 : 12 } ,
} ,
2026-01-06 21:55:59 +00:00
} ,
} ,
} ,
} ) ;
// 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 ;
2026-01-09 11:52:13 +00:00
// 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" ;
}
2026-01-06 13:52:00 +00:00
}
function closeModal ( ) {
2026-01-06 21:55:59 +00:00
elements . modal . classList . remove ( "active" ) ;
document . body . style . overflow = "" ;
state . currentSchoolData = null ;
2026-01-09 08:42:38 +00:00
2026-01-09 11:52:13 +00:00
// Clean up modal map
if ( modalMap ) {
modalMap . remove ( ) ;
modalMap = null ;
}
2026-01-09 08:42:38 +00:00
// Restore visibility of school maps
document . querySelectorAll ( '.school-map' ) . forEach ( el => {
el . style . visibility = 'visible' ;
} ) ;
2026-01-06 13:52:00 +00:00
}
function addToComparison ( school ) {
2026-01-06 21:55:59 +00:00
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 ) ;
2026-01-12 15:55:23 +00:00
saveSelectedSchoolsToStorage ( ) ;
2026-01-06 21:55:59 +00:00
renderSelectedSchools ( ) ;
2026-01-06 13:52:00 +00:00
}
function removeFromComparison ( urn ) {
2026-01-06 21:55:59 +00:00
state . selectedSchools = state . selectedSchools . filter ( ( s ) => s . urn !== urn ) ;
2026-01-12 15:55:23 +00:00
saveSelectedSchoolsToStorage ( ) ;
2026-01-06 21:55:59 +00:00
renderSelectedSchools ( ) ;
2026-01-06 13:52:00 +00:00
}
2026-01-12 15:55:23 +00:00
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 ) ;
}
}
2026-01-06 13:52:00 +00:00
function showEmptyState ( container , message ) {
2026-01-06 21:55:59 +00:00
container . innerHTML = `
2026-01-06 13:52:00 +00:00
< 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" / >
< / s v g >
< p > $ { escapeHtml ( message ) } < / p >
< / d i v >
` ;
}
function escapeHtml ( text ) {
2026-01-06 21:55:59 +00:00
if ( ! text ) return "" ;
const div = document . createElement ( "div" ) ;
div . textContent = text ;
return div . innerHTML ;
2026-01-06 13:52:00 +00:00
}
2026-01-06 16:30:32 +00:00
// =============================================================================
// EVENT LISTENERS
// =============================================================================
2026-01-06 13:52:00 +00:00
function setupEventListeners ( ) {
2026-01-06 21:55:59 +00:00
// Navigation
document . querySelectorAll ( ".nav-link" ) . forEach ( ( link ) => {
link . addEventListener ( "click" , ( e ) => {
e . preventDefault ( ) ;
const view = link . dataset . view ;
2026-01-08 22:59:55 +00:00
const path = view === "home" ? "/" : ` / ${ view } ` ;
2026-01-06 21:55:59 +00:00
navigateTo ( path ) ;
2026-01-06 13:52:00 +00:00
} ) ;
2026-01-06 21:55:59 +00:00
} ) ;
2026-01-07 15:04:30 +00:00
// 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
2026-01-06 21:55:59 +00:00
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 ( ) ;
}
2026-01-06 16:30:32 +00:00
} ) ;
2026-01-06 21:55:59 +00:00
}
2026-01-07 15:04:30 +00:00
if ( elements . typeFilterLocation ) {
elements . typeFilterLocation . addEventListener ( "change" , ( ) => {
if ( state . locationSearch . active ) {
state . pagination . page = 1 ;
loadSchools ( ) ;
}
} ) ;
}
2026-01-06 21:55:59 +00:00
// 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 = `
2026-01-06 15:58:15 +00:00
< div class = "compare-results-header" >
2026-01-06 21:55:59 +00:00
< span > $ { results . length } school$ { results . length !== 1 ? "s" : "" } found < / s p a n >
2026-01-06 15:58:15 +00:00
< 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" / >
< / s v g >
< / b u t t o n >
< / d i v >
` ;
2026-01-06 21:55:59 +00:00
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 ) => `
2026-01-06 15:58:15 +00:00
< div class = "compare-result-item" data - urn = "${school.urn}" data - name = "${escapeHtml(school.school_name)}" >
< div class = "name" > $ { escapeHtml ( school . school _name ) } < / d i v >
2026-01-06 21:55:59 +00:00
< div class = "location" > $ { escapeHtml ( school . local _authority || "" ) } $ { school . postcode ? " • " + escapeHtml ( school . postcode ) : "" } < / d i v >
2026-01-06 15:58:15 +00:00
< / d i v >
2026-01-06 21:55:59 +00:00
` ,
)
. join ( "" ) ;
elements . compareResults
. querySelectorAll ( ".compare-result-item" )
. forEach ( ( item ) => {
2026-01-06 22:50:14 +00:00
item . addEventListener ( "click" , ( e ) => {
e . stopPropagation ( ) ; // Prevent click-outside handler from closing results
2026-01-06 21:55:59 +00:00
const urn = parseInt ( item . dataset . urn ) ;
const school = data . schools . find ( ( s ) => s . urn === urn ) ;
if ( school ) {
addToComparison ( school ) ;
renderCompareResults ( data ) ;
2026-01-06 15:58:15 +00:00
}
2026-01-06 21:55:59 +00:00
} ) ;
} ) ;
}
// 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" ) ;
}
} ) ;
2026-01-06 22:45:54 +00:00
// 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" ) ;
} ) ;
2026-01-06 21:55:59 +00:00
// 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 ( ) ;
}
} ) ;
2026-01-06 13:52:00 +00:00
}
2026-01-09 15:10:39 +00:00
// =============================================================================
// 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 ) {
2026-01-12 09:35:53 +00:00
if ( ! e . target || ! e . target . closest ) return ;
2026-01-13 15:12:11 +00:00
const trigger = e . target . closest ( ".info-trigger, .warning-trigger" ) ;
2026-01-09 15:10:39 +00:00
if ( ! trigger ) return ;
clearTimeout ( this . hideTimeout ) ;
this . showTimeout = setTimeout ( ( ) => {
this . show ( trigger ) ;
} , 150 ) ;
}
handleMouseLeave ( e ) {
2026-01-12 09:35:53 +00:00
if ( ! e . target || ! e . target . closest ) return ;
2026-01-13 15:12:11 +00:00
const trigger = e . target . closest ( ".info-trigger, .warning-trigger" ) ;
2026-01-09 15:10:39 +00:00
const tooltip = e . target . closest ( ".tooltip" ) ;
if ( ! trigger && ! tooltip ) return ;
// Check if moving between trigger and tooltip
const relatedTarget = e . relatedTarget ;
if (
2026-01-13 15:12:11 +00:00
relatedTarget ? . closest ? . ( ".info-trigger, .warning-trigger" ) === this . activeTooltip ||
2026-01-12 09:35:53 +00:00
relatedTarget ? . closest ? . ( ".tooltip" )
2026-01-09 15:10:39 +00:00
) {
return ;
}
clearTimeout ( this . showTimeout ) ;
this . hideTimeout = setTimeout ( ( ) => {
this . hide ( ) ;
} , 100 ) ;
}
handleFocusIn ( e ) {
2026-01-12 09:35:53 +00:00
if ( ! e . target || ! e . target . closest ) return ;
2026-01-13 15:12:11 +00:00
const trigger = e . target . closest ( ".info-trigger, .warning-trigger" ) ;
2026-01-09 15:10:39 +00:00
if ( ! trigger ) return ;
clearTimeout ( this . hideTimeout ) ;
this . show ( trigger ) ;
}
handleFocusOut ( e ) {
2026-01-12 09:35:53 +00:00
if ( ! e . target || ! e . target . closest ) return ;
2026-01-13 15:12:11 +00:00
const trigger = e . target . closest ( ".info-trigger, .warning-trigger" ) ;
2026-01-09 15:10:39 +00:00
if ( ! trigger ) return ;
this . hideTimeout = setTimeout ( ( ) => {
this . hide ( ) ;
} , 100 ) ;
}
handleTouchClick ( e ) {
2026-01-13 15:12:11 +00:00
const trigger = e . target . closest ( ".info-trigger, .warning-trigger" ) ;
2026-01-09 15:10:39 +00:00
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 ) {
2026-01-13 15:12:11 +00:00
// 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" ) ;
2026-01-09 15:10:39 +00:00
}
// 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 ;