implementing map on school card; adding more school details
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 59s

This commit is contained in:
Tudor
2026-01-08 23:20:42 +00:00
parent 34f40c0c1c
commit b7943e1042
5 changed files with 294 additions and 14 deletions

View File

@@ -65,11 +65,11 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
# Content Security Policy
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; "
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://unpkg.com; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net https://unpkg.com; "
"font-src 'self' https://fonts.gstatic.com; "
"img-src 'self' data:; "
"connect-src 'self' https://cdn.jsdelivr.net; "
"img-src 'self' data: https://*.tile.openstreetmap.org; "
"connect-src 'self' https://cdn.jsdelivr.net https://*.tile.openstreetmap.org; "
"frame-ancestors 'none'; "
"base-uri 'self'; "
"form-action 'self';"
@@ -306,11 +306,8 @@ async def get_schools(
end_idx = start_idx + page_size
schools_df = schools_df.iloc[start_idx:end_idx]
# Remove internal columns before sending (keep distance if present)
output_cols = [c for c in schools_df.columns if c not in ["latitude", "longitude"]]
return {
"schools": clean_for_json(schools_df[output_cols]),
"schools": clean_for_json(schools_df),
"total": total,
"page": page,
"page_size": page_size,

View File

@@ -405,6 +405,10 @@ SCHOOL_COLUMNS = [
"address",
"town",
"postcode",
"religious_denomination",
"age_range",
"latitude",
"longitude",
]
# Local Authority code to name mapping (for fallback when LANAME column missing)

View File

@@ -48,6 +48,9 @@ const CHART_COLORS = [
"#9b59b6", // violet
];
// Map instances (stored to allow cleanup)
const schoolMaps = new Map();
// Helper to get chart aspect ratio based on screen size
function getChartAspectRatio() {
return window.innerWidth <= 768 ? 1.2 : 2;
@@ -471,15 +474,34 @@ function renderFeaturedSchools(schools) {
<p>Start typing to search schools across England</p>
</div>
${schools
.map(
(school) => `
.map((school) => {
// Religious denomination tag (only show if meaningful)
const faithTag = school.religious_denomination &&
!["None", "Does not apply", ""].includes(school.religious_denomination)
? `<span class="school-tag faith">${escapeHtml(school.religious_denomination)}</span>`
: "";
// Age range display
const ageRange = school.age_range
? `<span class="age-range">Ages ${escapeHtml(school.age_range)}</span>`
: "";
// Map container (only if coordinates available)
const hasCoords = school.latitude && school.longitude;
const mapContainer = hasCoords
? `<div class="school-map" data-lat="${school.latitude}" data-lng="${school.longitude}" data-name="${escapeHtml(school.school_name)}"></div>`
: "";
return `
<div class="school-card featured" data-urn="${school.urn}">
<h3 class="school-name">${escapeHtml(school.school_name)}</h3>
<div class="school-meta">
<span class="school-tag">${escapeHtml(school.local_authority || "")}</span>
<span class="school-tag type">${escapeHtml(school.school_type || "")}</span>
${faithTag}
</div>
<div class="school-address">${escapeHtml(school.address || "")}</div>
${ageRange ? `<div class="school-details">${ageRange}</div>` : ""}
<div class="school-stats">
<div class="stat">
<div class="stat-value">${formatMetricValue(school.rwm_expected_pct, "rwm_expected_pct")}</div>
@@ -490,15 +512,21 @@ function renderFeaturedSchools(schools) {
<div class="stat-label">Pupils</div>
</div>
</div>
${mapContainer}
</div>
`,
)
`;
})
.join("")}
`;
// Initialize maps
initializeSchoolMaps(elements.schoolsGrid);
// Add click handlers
elements.schoolsGrid.querySelectorAll(".school-card").forEach((card) => {
card.addEventListener("click", () => {
card.addEventListener("click", (e) => {
// Don't trigger if clicking on map
if (e.target.closest(".school-map")) return;
const urn = parseInt(card.dataset.urn);
openSchoolModal(urn);
});
@@ -574,6 +602,118 @@ function formatMetricValue(value, metric) {
return value.toFixed(1);
}
// =============================================================================
// MAP FUNCTIONS
// =============================================================================
/**
* Initialize Leaflet maps for all school cards in a container
*/
function initializeSchoolMaps(container) {
// Clean up existing maps first
container.querySelectorAll(".school-map").forEach((mapEl) => {
const existingMap = schoolMaps.get(mapEl);
if (existingMap) {
existingMap.remove();
schoolMaps.delete(mapEl);
}
});
// Initialize new maps
container.querySelectorAll(".school-map").forEach((mapEl) => {
const lat = parseFloat(mapEl.dataset.lat);
const lng = parseFloat(mapEl.dataset.lng);
const schoolName = mapEl.dataset.name;
if (isNaN(lat) || isNaN(lng)) 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);
});
});
}
/**
* Open fullscreen map modal
*/
function openMapModal(lat, lng, schoolName) {
// Create modal overlay
const overlay = document.createElement("div");
overlay.className = "map-modal-overlay";
overlay.innerHTML = `
<div class="map-modal">
<div class="map-modal-header">
<h3>${escapeHtml(schoolName)}</h3>
<button class="map-modal-close" aria-label="Close map">&times;</button>
</div>
<div class="map-modal-content" id="fullscreen-map"></div>
</div>
`;
document.body.appendChild(overlay);
document.body.style.overflow = "hidden";
// Initialize fullscreen map
const mapContainer = document.getElementById("fullscreen-map");
const fullMap = L.map(mapContainer, {
center: [lat, lng],
zoom: 16,
});
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).addTo(fullMap);
const marker = L.marker([lat, lng]).addTo(fullMap);
marker.bindPopup(`<strong>${escapeHtml(schoolName)}</strong>`).openPopup();
// Close handlers
const closeModal = () => {
fullMap.remove();
overlay.remove();
document.body.style.overflow = "";
};
overlay.querySelector(".map-modal-close").addEventListener("click", closeModal);
overlay.addEventListener("click", (e) => {
if (e.target === overlay) closeModal();
});
// Close on Escape key
const escHandler = (e) => {
if (e.key === "Escape") {
closeModal();
document.removeEventListener("keydown", escHandler);
}
};
document.addEventListener("keydown", escHandler);
}
// =============================================================================
// RENDER FUNCTIONS
// =============================================================================
@@ -594,14 +734,33 @@ function renderSchools(schools) {
? `<span class="distance-badge">${school.distance.toFixed(1)} mi</span>`
: "";
// Religious denomination tag (only show if meaningful)
const faithTag = school.religious_denomination &&
!["None", "Does not apply", ""].includes(school.religious_denomination)
? `<span class="school-tag faith">${escapeHtml(school.religious_denomination)}</span>`
: "";
// Age range display
const ageRange = school.age_range
? `<span class="age-range">Ages ${escapeHtml(school.age_range)}</span>`
: "";
// Map container (only if coordinates available)
const hasCoords = school.latitude && school.longitude;
const mapContainer = hasCoords
? `<div class="school-map" data-lat="${school.latitude}" data-lng="${school.longitude}" data-name="${escapeHtml(school.school_name)}"></div>`
: "";
return `
<div class="school-card" data-urn="${school.urn}">
<h3 class="school-name">${escapeHtml(school.school_name)}${distanceBadge}</h3>
<div class="school-meta">
<span class="school-tag">${escapeHtml(school.local_authority || "")}</span>
<span class="school-tag type">${escapeHtml(school.school_type || "")}</span>
${faithTag}
</div>
<div class="school-address">${escapeHtml(school.address || "")}</div>
${ageRange ? `<div class="school-details">${ageRange}</div>` : ""}
<div class="school-stats">
<div class="stat">
<div class="stat-value">${formatMetricValue(school.rwm_expected_pct, "rwm_expected_pct")}</div>
@@ -612,6 +771,7 @@ function renderSchools(schools) {
<div class="stat-label">Pupils</div>
</div>
</div>
${mapContainer}
</div>
`;
})
@@ -633,9 +793,14 @@ function renderSchools(schools) {
elements.schoolsGrid.innerHTML = html;
// Initialize maps
initializeSchoolMaps(elements.schoolsGrid);
// Add click handlers
elements.schoolsGrid.querySelectorAll(".school-card").forEach((card) => {
card.addEventListener("click", () => {
card.addEventListener("click", (e) => {
// Don't trigger if clicking on map
if (e.target.closest(".school-map")) return;
const urn = parseInt(card.dataset.urn);
openSchoolModal(urn);
});

View File

@@ -57,6 +57,9 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=Playfair+Display:wght@600;700&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<!-- Leaflet Map Library -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<link rel="stylesheet" href="/static/styles.css">
<!-- Cookie Consent Banner -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/silktide/consent-manager@main/silktide-consent-manager.css">

View File

@@ -493,12 +493,32 @@ body {
color: var(--accent-teal);
}
.school-tag.faith {
background: rgba(138, 43, 226, 0.1);
color: #8a2be2;
}
.school-address {
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.school-details {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
.age-range {
font-size: 0.75rem;
color: var(--text-secondary);
padding: 0.2rem 0.5rem;
background: var(--bg-secondary);
border-radius: var(--radius-sm);
}
.school-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
@@ -532,6 +552,97 @@ body {
letter-spacing: 0.05em;
}
/* School Card Map */
.school-map {
height: 150px;
margin-top: 1rem;
border-radius: var(--radius-md);
overflow: hidden;
cursor: pointer;
border: 1px solid var(--border-color);
transition: var(--transition);
}
.school-map:hover {
border-color: var(--accent-coral);
box-shadow: var(--shadow-small);
}
/* Fullscreen Map Modal */
.map-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
animation: fadeIn 0.2s ease;
}
.map-modal {
background: var(--bg-card);
border-radius: var(--radius-lg);
width: 100%;
max-width: 900px;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: var(--shadow-large);
animation: slideUp 0.3s ease;
}
.map-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.map-modal-header h3 {
font-family: 'Playfair Display', Georgia, serif;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.map-modal-close {
background: none;
border: none;
font-size: 1.75rem;
color: var(--text-muted);
cursor: pointer;
padding: 0.25rem 0.5rem;
line-height: 1;
transition: var(--transition);
}
.map-modal-close:hover {
color: var(--accent-coral);
}
.map-modal-content {
height: 500px;
width: 100%;
}
@media (max-width: 768px) {
.map-modal {
max-height: 80vh;
}
.map-modal-content {
height: 400px;
}
}
/* Section Titles */
.section-title {
font-family: 'Playfair Display', Georgia, serif;