location search beta 1
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m3s

This commit is contained in:
Tudor Sitaru
2026-01-06 16:59:25 +00:00
parent 7684ceb9c0
commit bd3640d50f
6 changed files with 484 additions and 35 deletions

View File

@@ -5,6 +5,7 @@ Uses real data from UK Government Compare School Performance downloads.
""" """
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import pandas as pd
from fastapi import FastAPI, HTTPException, Query from fastapi import FastAPI, HTTPException, Query
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
@@ -13,7 +14,7 @@ from typing import Optional
from .config import settings from .config import settings
from .schemas import METRIC_DEFINITIONS, RANKING_COLUMNS, SCHOOL_COLUMNS from .schemas import METRIC_DEFINITIONS, RANKING_COLUMNS, SCHOOL_COLUMNS
from .data_loader import load_school_data, clear_cache from .data_loader import load_school_data, clear_cache, geocode_single_postcode, geocode_postcodes_bulk, haversine_distance
from .utils import clean_for_json from .utils import clean_for_json
@@ -54,11 +55,25 @@ async def root():
return FileResponse(settings.frontend_dir / "index.html") return FileResponse(settings.frontend_dir / "index.html")
@app.get("/compare")
async def serve_compare():
"""Serve the frontend for /compare route (SPA routing)."""
return FileResponse(settings.frontend_dir / "index.html")
@app.get("/rankings")
async def serve_rankings():
"""Serve the frontend for /rankings route (SPA routing)."""
return FileResponse(settings.frontend_dir / "index.html")
@app.get("/api/schools") @app.get("/api/schools")
async def get_schools( async def get_schools(
search: Optional[str] = Query(None, description="Search by school name"), search: Optional[str] = Query(None, description="Search by school name"),
local_authority: Optional[str] = Query(None, description="Filter by local authority"), local_authority: Optional[str] = Query(None, description="Filter by local authority"),
school_type: Optional[str] = Query(None, description="Filter by school type"), school_type: Optional[str] = Query(None, description="Filter by school type"),
postcode: Optional[str] = Query(None, description="Search near postcode"),
radius: float = Query(5.0, ge=0.1, le=50, description="Search radius in miles"),
page: int = Query(1, ge=1, description="Page number"), page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(None, ge=1, le=100, description="Results per page"), page_size: int = Query(None, ge=1, le=100, description="Results per page"),
): ):
@@ -66,6 +81,7 @@ async def get_schools(
Get list of unique primary schools with pagination. Get list of unique primary schools with pagination.
Returns paginated results with total count for efficient loading. Returns paginated results with total count for efficient loading.
Supports location-based search using postcode.
""" """
df = load_school_data() df = load_school_data()
@@ -80,9 +96,45 @@ async def get_schools(
latest_year = df.groupby('urn')['year'].max().reset_index() latest_year = df.groupby('urn')['year'].max().reset_index()
df_latest = df.merge(latest_year, on=['urn', 'year']) df_latest = df.merge(latest_year, on=['urn', 'year'])
available_cols = [c for c in SCHOOL_COLUMNS if c in df_latest.columns] # Include lat/long in columns for location search
location_cols = ['latitude', 'longitude']
available_cols = [c for c in SCHOOL_COLUMNS + location_cols if c in df_latest.columns]
schools_df = df_latest[available_cols].drop_duplicates(subset=['urn']) schools_df = df_latest[available_cols].drop_duplicates(subset=['urn'])
# Location-based search
search_coords = None
if postcode:
coords = geocode_single_postcode(postcode)
if coords:
search_coords = coords
schools_df = schools_df.copy()
# Geocode school postcodes on-demand if not already cached
if 'postcode' in schools_df.columns:
unique_postcodes = schools_df['postcode'].dropna().unique().tolist()
geocoded = geocode_postcodes_bulk(unique_postcodes)
# Add lat/long from geocoded data
schools_df['latitude'] = schools_df['postcode'].apply(
lambda pc: geocoded.get(str(pc).strip().upper(), (None, None))[0] if pd.notna(pc) else None
)
schools_df['longitude'] = schools_df['postcode'].apply(
lambda pc: geocoded.get(str(pc).strip().upper(), (None, None))[1] if pd.notna(pc) else None
)
# Filter by distance
def calc_distance(row):
if pd.isna(row.get('latitude')) or pd.isna(row.get('longitude')):
return float('inf')
return haversine_distance(
search_coords[0], search_coords[1],
row['latitude'], row['longitude']
)
schools_df['distance'] = schools_df.apply(calc_distance, axis=1)
schools_df = schools_df[schools_df['distance'] <= radius]
schools_df = schools_df.sort_values('distance')
# Apply filters # Apply filters
if search: if search:
search_lower = search.lower() search_lower = search.lower()
@@ -103,12 +155,18 @@ async def get_schools(
end_idx = start_idx + page_size end_idx = start_idx + page_size
schools_df = schools_df.iloc[start_idx:end_idx] schools_df = schools_df.iloc[start_idx:end_idx]
# Remove internal columns before sending
output_cols = [c for c in schools_df.columns if c not in ['latitude', 'longitude']]
if 'distance' in schools_df.columns:
output_cols.append('distance')
return { return {
"schools": clean_for_json(schools_df), "schools": clean_for_json(schools_df[output_cols]),
"total": total, "total": total,
"page": page, "page": page,
"page_size": page_size, "page_size": page_size,
"total_pages": (total + page_size - 1) // page_size if page_size > 0 else 0, "total_pages": (total + page_size - 1) // page_size if page_size > 0 else 0,
"search_location": {"postcode": postcode, "radius": radius} if search_coords else None,
} }

View File

@@ -8,7 +8,8 @@ import numpy as np
from pathlib import Path from pathlib import Path
from functools import lru_cache from functools import lru_cache
import re import re
from typing import Optional import requests
from typing import Optional, Dict, Tuple
from .config import settings from .config import settings
from .schemas import ( from .schemas import (
@@ -19,6 +20,78 @@ from .schemas import (
LA_CODE_TO_NAME, LA_CODE_TO_NAME,
) )
# Cache for postcode geocoding
_postcode_cache: Dict[str, Tuple[float, float]] = {}
def geocode_postcodes_bulk(postcodes: list) -> Dict[str, Tuple[float, float]]:
"""
Geocode postcodes in bulk using postcodes.io API.
Returns dict of postcode -> (latitude, longitude).
"""
results = {}
# Remove invalid postcodes and deduplicate
valid_postcodes = [p.strip().upper() for p in postcodes if p and isinstance(p, str) and len(p.strip()) >= 5]
valid_postcodes = list(set(valid_postcodes))
if not valid_postcodes:
return results
# postcodes.io allows max 100 postcodes per request
batch_size = 100
for i in range(0, len(valid_postcodes), batch_size):
batch = valid_postcodes[i:i + batch_size]
try:
response = requests.post(
'https://api.postcodes.io/postcodes',
json={'postcodes': batch},
timeout=30
)
if response.status_code == 200:
data = response.json()
for item in data.get('result', []):
if item and item.get('result'):
pc = item['query'].upper()
lat = item['result'].get('latitude')
lon = item['result'].get('longitude')
if lat and lon:
results[pc] = (lat, lon)
except Exception as e:
print(f" Warning: Geocoding batch failed: {e}")
return results
def geocode_single_postcode(postcode: str) -> Optional[Tuple[float, float]]:
"""Geocode a single postcode using postcodes.io API."""
if not postcode:
return None
postcode = postcode.strip().upper()
# Check cache first
if postcode in _postcode_cache:
return _postcode_cache[postcode]
try:
response = requests.get(
f'https://api.postcodes.io/postcodes/{postcode}',
timeout=10
)
if response.status_code == 200:
data = response.json()
if data.get('result'):
lat = data['result'].get('latitude')
lon = data['result'].get('longitude')
if lat and lon:
_postcode_cache[postcode] = (lat, lon)
return (lat, lon)
except Exception:
pass
return None
def extract_year_from_folder(folder_name: str) -> Optional[int]: def extract_year_from_folder(folder_name: str) -> Optional[int]:
"""Extract the end year from folder name like '2023-2024' -> 2024.""" """Extract the end year from folder name like '2023-2024' -> 2024."""
@@ -151,6 +224,10 @@ def load_year_data(year_folder: Path, year: int) -> Optional[pd.DataFrame]:
if col in df.columns: if col in df.columns:
df[col] = parse_numeric_vectorized(df[col]) df[col] = parse_numeric_vectorized(df[col])
# Initialize lat/long columns
df['latitude'] = None
df['longitude'] = None
print(f" Loaded {len(df)} schools for year {year}") print(f" Loaded {len(df)} schools for year {year}")
return df return df
@@ -184,6 +261,10 @@ def load_school_data() -> pd.DataFrame:
print(f"\nTotal records loaded: {len(result)}") print(f"\nTotal records loaded: {len(result)}")
print(f"Unique schools: {result['urn'].nunique()}") print(f"Unique schools: {result['urn'].nunique()}")
print(f"Years: {sorted(result['year'].unique())}") print(f"Years: {sorted(result['year'].unique())}")
# Note: Geocoding is done lazily when location search is used
# This keeps startup fast
return result return result
else: else:
print("No data files found. Creating empty DataFrame.") print("No data files found. Creating empty DataFrame.")
@@ -194,3 +275,24 @@ def clear_cache():
"""Clear the data cache to force reload.""" """Clear the data cache to force reload."""
load_school_data.cache_clear() load_school_data.cache_clear()
def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""
Calculate the great circle distance between two points on Earth (in miles).
"""
from math import radians, cos, sin, asin, sqrt
# Convert to radians
lat1, lon1, lat2, lon2 = map(radians, [lat1, lon1, lat2, lon2])
# Haversine formula
dlat = lat2 - lat1
dlon = lon2 - lon1
a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2
c = 2 * asin(sqrt(a))
# Earth's radius in miles
r = 3956
return c * r

View File

@@ -17,6 +17,11 @@ const state = {
metrics: null, // Cached metric definitions metrics: null, // Cached metric definitions
pagination: { page: 1, pageSize: 50, total: 0, totalPages: 0 }, pagination: { page: 1, pageSize: 50, total: 0, totalPages: 0 },
isShowingFeatured: true, // Whether showing featured schools vs search results isShowingFeatured: true, // Whether showing featured schools vs search results
locationSearch: {
active: false,
postcode: null,
radius: 5,
},
loading: { loading: {
schools: false, schools: false,
filters: false, filters: false,
@@ -50,6 +55,10 @@ const elements = {
schoolSearch: document.getElementById('school-search'), schoolSearch: document.getElementById('school-search'),
localAuthorityFilter: document.getElementById('local-authority-filter'), localAuthorityFilter: document.getElementById('local-authority-filter'),
typeFilter: document.getElementById('type-filter'), typeFilter: document.getElementById('type-filter'),
postcodeSearch: document.getElementById('postcode-search'),
radiusSelect: document.getElementById('radius-select'),
locationSearchBtn: document.getElementById('location-search-btn'),
clearLocationBtn: document.getElementById('clear-location-btn'),
schoolsGrid: document.getElementById('schools-grid'), schoolsGrid: document.getElementById('schools-grid'),
compareSearch: document.getElementById('compare-search'), compareSearch: document.getElementById('compare-search'),
compareResults: document.getElementById('compare-results'), compareResults: document.getElementById('compare-results'),
@@ -60,6 +69,7 @@ const elements = {
comparisonTable: document.getElementById('comparison-table'), comparisonTable: document.getElementById('comparison-table'),
tableHeader: document.getElementById('table-header'), tableHeader: document.getElementById('table-header'),
tableBody: document.getElementById('table-body'), tableBody: document.getElementById('table-body'),
rankingArea: document.getElementById('ranking-area'),
rankingMetric: document.getElementById('ranking-metric'), rankingMetric: document.getElementById('ranking-metric'),
rankingYear: document.getElementById('ranking-year'), rankingYear: document.getElementById('ranking-year'),
rankingsList: document.getElementById('rankings-list'), rankingsList: document.getElementById('rankings-list'),
@@ -163,6 +173,39 @@ function renderLoadingSkeleton(count, type = 'card') {
`).join(''); `).join('');
} }
// =============================================================================
// ROUTING
// =============================================================================
const routes = {
'/': 'dashboard',
'/compare': 'compare',
'/rankings': 'rankings',
};
function navigateTo(path) {
// Update URL without reload
window.history.pushState({}, '', path);
handleRoute();
}
function handleRoute() {
const path = window.location.pathname;
const view = routes[path] || 'dashboard';
// Update navigation
document.querySelectorAll('.nav-link').forEach(link => {
link.classList.toggle('active', link.dataset.view === view);
});
// Update view
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
const viewElement = document.getElementById(`${view}-view`);
if (viewElement) {
viewElement.classList.add('active');
}
}
// ============================================================================= // =============================================================================
// INITIALIZATION // INITIALIZATION
// ============================================================================= // =============================================================================
@@ -192,10 +235,16 @@ async function init() {
await loadRankings(); await loadRankings();
setupEventListeners(); setupEventListeners();
// Handle initial route
handleRoute();
// Handle browser back/forward
window.addEventListener('popstate', handleRoute);
} }
function populateFilters(data) { function populateFilters(data) {
// Populate local authority filter // Populate local authority filter (dashboard)
data.local_authorities.forEach(la => { data.local_authorities.forEach(la => {
const option = document.createElement('option'); const option = document.createElement('option');
option.value = la; option.value = la;
@@ -211,6 +260,14 @@ function populateFilters(data) {
elements.typeFilter.appendChild(option); elements.typeFilter.appendChild(option);
}); });
// Populate ranking area dropdown
data.local_authorities.forEach(la => {
const option = document.createElement('option');
option.value = la;
option.textContent = la;
elements.rankingArea.appendChild(option);
});
// Populate ranking year dropdown // Populate ranking year dropdown
elements.rankingYear.innerHTML = ''; elements.rankingYear.innerHTML = '';
data.years.sort((a, b) => b - a).forEach(year => { data.years.sort((a, b) => b - a).forEach(year => {
@@ -229,9 +286,10 @@ async function loadSchools() {
const search = elements.schoolSearch.value.trim(); const search = elements.schoolSearch.value.trim();
const localAuthority = elements.localAuthorityFilter.value; const localAuthority = elements.localAuthorityFilter.value;
const type = elements.typeFilter.value; const type = elements.typeFilter.value;
const { active: locationActive, postcode, radius } = state.locationSearch;
// If no search query (or less than 2 chars) and no filters, show featured schools // If no search query (or less than 2 chars) and no filters and no location search, show featured schools
if (search.length < 2 && !localAuthority && !type) { if (search.length < 2 && !localAuthority && !type && !locationActive) {
await loadFeaturedSchools(); await loadFeaturedSchools();
return; return;
} }
@@ -242,6 +300,12 @@ async function loadSchools() {
if (localAuthority) params.append('local_authority', localAuthority); if (localAuthority) params.append('local_authority', localAuthority);
if (type) params.append('school_type', type); if (type) params.append('school_type', type);
// Add location search params
if (locationActive && postcode) {
params.append('postcode', postcode);
params.append('radius', radius);
}
params.append('page', state.pagination.page); params.append('page', state.pagination.page);
params.append('page_size', state.pagination.pageSize); params.append('page_size', state.pagination.pageSize);
@@ -261,10 +325,16 @@ async function loadSchools() {
state.pagination.totalPages = data.total_pages; state.pagination.totalPages = data.total_pages;
state.isShowingFeatured = false; state.isShowingFeatured = false;
// Show location info banner if location search is active
updateLocationInfoBanner(data.search_location);
renderSchools(state.schools); renderSchools(state.schools);
} }
async function loadFeaturedSchools() { async function loadFeaturedSchools() {
// Clear location info when showing featured
updateLocationInfoBanner(null);
// Load a sample of schools and pick 3 random ones // Load a sample of schools and pick 3 random ones
const data = await fetchAPI('/api/schools?page_size=100', { showLoading: 'schools' }); const data = await fetchAPI('/api/schools?page_size=100', { showLoading: 'schools' });
@@ -281,6 +351,82 @@ async function loadFeaturedSchools() {
renderFeaturedSchools(state.schools); renderFeaturedSchools(state.schools);
} }
function updateLocationInfoBanner(searchLocation) {
// Remove existing banner if any
const existingBanner = document.querySelector('.location-info');
if (existingBanner) {
existingBanner.remove();
}
if (!searchLocation) {
return;
}
// Create location info banner
const banner = document.createElement('div');
banner.className = 'location-info';
banner.innerHTML = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
<span>Showing schools within ${searchLocation.radius} miles of <strong>${searchLocation.postcode.toUpperCase()}</strong></span>
`;
// Insert banner before the schools grid
elements.schoolsGrid.parentNode.insertBefore(banner, elements.schoolsGrid);
}
async function searchByLocation() {
const postcode = elements.postcodeSearch.value.trim();
const radius = parseFloat(elements.radiusSelect.value);
if (!postcode) {
alert('Please enter a postcode');
return;
}
// Validate UK postcode format (basic check)
const postcodeRegex = /^[A-Z]{1,2}[0-9][A-Z0-9]?\s*[0-9][A-Z]{2}$/i;
if (!postcodeRegex.test(postcode)) {
alert('Please enter a valid UK postcode (e.g. SW18 4TF)');
return;
}
// Update state
state.locationSearch = {
active: true,
postcode: postcode,
radius: radius,
};
state.pagination.page = 1;
// Show clear button
elements.clearLocationBtn.style.display = 'inline-flex';
// Load schools with location filter
await loadSchools();
}
function clearLocationSearch() {
state.locationSearch = {
active: false,
postcode: null,
radius: 5,
};
state.pagination.page = 1;
// Clear input
elements.postcodeSearch.value = '';
elements.radiusSelect.value = '5';
// Hide clear button
elements.clearLocationBtn.style.display = 'none';
// Reload schools (will show featured if no other filters)
loadSchools();
}
function renderFeaturedSchools(schools) { function renderFeaturedSchools(schools) {
elements.schoolsGrid.innerHTML = ` elements.schoolsGrid.innerHTML = `
<div class="featured-header"> <div class="featured-header">
@@ -332,13 +478,15 @@ async function loadComparison() {
} }
async function loadRankings() { async function loadRankings() {
const area = elements.rankingArea.value;
const metric = elements.rankingMetric.value; const metric = elements.rankingMetric.value;
const year = elements.rankingYear.value; const year = elements.rankingYear.value;
let endpoint = `/api/rankings?metric=${metric}&limit=20`; let endpoint = `/api/rankings?metric=${metric}&limit=20`;
if (year) endpoint += `&year=${year}`; if (year) endpoint += `&year=${year}`;
if (area) endpoint += `&local_authority=${encodeURIComponent(area)}`;
const data = await fetchAPI(endpoint, { showLoading: 'rankings' }); const data = await fetchAPI(endpoint, { useCache: false, showLoading: 'rankings' });
if (!data) { if (!data) {
showEmptyState(elements.rankingsList, 'Unable to load rankings'); showEmptyState(elements.rankingsList, 'Unable to load rankings');
@@ -384,30 +532,39 @@ function formatMetricValue(value, metric) {
function renderSchools(schools) { function renderSchools(schools) {
if (schools.length === 0) { if (schools.length === 0) {
showEmptyState(elements.schoolsGrid, 'No primary schools found matching your criteria'); 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; return;
} }
let html = schools.map(school => ` let html = schools.map(school => {
<div class="school-card" data-urn="${school.urn}"> const distanceBadge = school.distance !== undefined && school.distance !== null
<h3 class="school-name">${escapeHtml(school.school_name)}</h3> ? `<span class="distance-badge">${school.distance.toFixed(1)} mi</span>`
<div class="school-meta"> : '';
<span class="school-tag">${escapeHtml(school.local_authority || '')}</span>
<span class="school-tag type">${escapeHtml(school.school_type || '')}</span> return `
</div> <div class="school-card" data-urn="${school.urn}">
<div class="school-address">${escapeHtml(school.address || '')}</div> <h3 class="school-name">${escapeHtml(school.school_name)}${distanceBadge}</h3>
<div class="school-stats"> <div class="school-meta">
<div class="stat"> <span class="school-tag">${escapeHtml(school.local_authority || '')}</span>
<div class="stat-value">Primary</div> <span class="school-tag type">${escapeHtml(school.school_type || '')}</span>
<div class="stat-label">Phase</div>
</div> </div>
<div class="stat"> <div class="school-address">${escapeHtml(school.address || '')}</div>
<div class="stat-value">KS2</div> <div class="school-stats">
<div class="stat-label">Data</div> <div class="stat">
<div class="stat-value">Primary</div>
<div class="stat-label">Phase</div>
</div>
<div class="stat">
<div class="stat-value">KS2</div>
<div class="stat-label">Data</div>
</div>
</div> </div>
</div> </div>
</div> `;
`).join(''); }).join('');
// Add pagination info // Add pagination info
if (state.pagination.totalPages > 1) { if (state.pagination.totalPages > 1) {
@@ -919,12 +1076,8 @@ function setupEventListeners() {
link.addEventListener('click', (e) => { link.addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
const view = link.dataset.view; const view = link.dataset.view;
const path = view === 'dashboard' ? '/' : `/${view}`;
document.querySelectorAll('.nav-link').forEach(l => l.classList.remove('active')); navigateTo(path);
link.classList.add('active');
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
document.getElementById(`${view}-view`).classList.add('active');
}); });
}); });
@@ -945,6 +1098,22 @@ function setupEventListeners() {
loadSchools(); loadSchools();
}); });
// Location search
if (elements.locationSearchBtn) {
elements.locationSearchBtn.addEventListener('click', searchByLocation);
}
if (elements.clearLocationBtn) {
elements.clearLocationBtn.addEventListener('click', clearLocationSearch);
}
if (elements.postcodeSearch) {
elements.postcodeSearch.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
searchByLocation();
}
});
}
// Compare search // Compare search
let compareSearchTimeout; let compareSearchTimeout;
let lastCompareSearchData = null; let lastCompareSearchData = null;
@@ -1027,6 +1196,7 @@ function setupEventListeners() {
elements.metricSelect.addEventListener('change', updateComparisonChart); elements.metricSelect.addEventListener('change', updateComparisonChart);
// Rankings // Rankings
elements.rankingArea.addEventListener('change', loadRankings);
elements.rankingMetric.addEventListener('change', loadRankings); elements.rankingMetric.addEventListener('change', loadRankings);
elements.rankingYear.addEventListener('change', loadRankings); elements.rankingYear.addEventListener('change', loadRankings);

View File

@@ -29,9 +29,9 @@
</div> </div>
</div> </div>
<nav class="nav"> <nav class="nav">
<a href="#" class="nav-link active" data-view="dashboard">Dashboard</a> <a href="/" class="nav-link active" data-view="dashboard">Dashboard</a>
<a href="#" class="nav-link" data-view="compare">Compare</a> <a href="/compare" class="nav-link" data-view="compare">Compare</a>
<a href="#" class="nav-link" data-view="rankings">Rankings</a> <a href="/rankings" class="nav-link" data-view="rankings">Rankings</a>
</nav> </nav>
</div> </div>
</header> </header>
@@ -54,6 +54,20 @@
</svg> </svg>
</div> </div>
</div> </div>
<div class="location-search">
<div class="location-input-group">
<input type="text" id="postcode-search" class="search-input postcode-input" placeholder="Enter postcode (e.g. SW18 4TF)">
<select id="radius-select" class="filter-select radius-select">
<option value="1">1 mile</option>
<option value="2">2 miles</option>
<option value="5" selected>5 miles</option>
<option value="10">10 miles</option>
<option value="20">20 miles</option>
</select>
<button id="location-search-btn" class="btn btn-primary location-btn">Find Nearby</button>
<button id="clear-location-btn" class="btn location-clear-btn" style="display: none;">Clear</button>
</div>
</div>
<div class="filter-row"> <div class="filter-row">
<select id="local-authority-filter" class="filter-select"> <select id="local-authority-filter" class="filter-select">
<option value="">All Areas</option> <option value="">All Areas</option>
@@ -172,6 +186,10 @@
</div> </div>
<div class="rankings-controls"> <div class="rankings-controls">
<select id="ranking-area" class="filter-select">
<option value="">All Areas</option>
<!-- Populated by JS -->
</select>
<select id="ranking-metric" class="filter-select"> <select id="ranking-metric" class="filter-select">
<optgroup label="Expected Standard"> <optgroup label="Expected Standard">
<option value="rwm_expected_pct">Reading, Writing & Maths Combined %</option> <option value="rwm_expected_pct">Reading, Writing & Maths Combined %</option>

View File

@@ -257,6 +257,86 @@ body {
transition: var(--transition); transition: var(--transition);
} }
/* Location Search */
.location-search {
margin-bottom: 1rem;
}
.location-input-group {
display: flex;
gap: 0.75rem;
align-items: center;
justify-content: center;
flex-wrap: wrap;
}
.postcode-input {
width: auto;
max-width: 180px;
padding: 0.6rem 1rem;
font-size: 0.95rem;
text-transform: uppercase;
}
.radius-select {
width: auto;
min-width: 100px;
}
.location-btn {
padding: 0.6rem 1.25rem;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.location-clear-btn {
padding: 0.6rem 1rem;
font-size: 0.9rem;
background: var(--bg-secondary);
color: var(--text-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
cursor: pointer;
transition: var(--transition);
}
.location-clear-btn:hover {
background: var(--border-color);
color: var(--text-primary);
}
.location-info {
text-align: center;
margin-bottom: 1rem;
padding: 0.75rem 1.25rem;
background: var(--accent-teal);
color: var(--text-inverse);
border-radius: var(--radius-md);
font-size: 0.9rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.location-info svg {
width: 16px;
height: 16px;
}
.distance-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
background: var(--accent-teal);
color: var(--text-inverse);
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: 600;
margin-left: 0.5rem;
}
.filter-select:focus { .filter-select:focus {
outline: none; outline: none;
border-color: var(--accent-teal); border-color: var(--accent-teal);
@@ -1056,6 +1136,26 @@ body {
flex-direction: column; flex-direction: column;
} }
.location-input-group {
flex-direction: column;
width: 100%;
}
.postcode-input {
max-width: 100%;
width: 100%;
}
.radius-select {
width: 100%;
}
.location-btn,
.location-clear-btn {
width: 100%;
justify-content: center;
}
.schools-grid { .schools-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

View File

@@ -4,4 +4,5 @@ pandas==2.1.4
python-multipart==0.0.6 python-multipart==0.0.6
aiofiles==23.2.1 aiofiles==23.2.1
pydantic-settings==2.1.0 pydantic-settings==2.1.0
requests==2.31.0