From b7943e104262bea4a5c13d259a2b18cd1d255a0e Mon Sep 17 00:00:00 2001 From: Tudor Date: Thu, 8 Jan 2026 23:20:42 +0000 Subject: [PATCH] implementing map on school card; adding more school details --- backend/app.py | 13 ++-- backend/schemas.py | 4 + frontend/app.js | 177 ++++++++++++++++++++++++++++++++++++++++++-- frontend/index.html | 3 + frontend/styles.css | 111 +++++++++++++++++++++++++++ 5 files changed, 294 insertions(+), 14 deletions(-) diff --git a/backend/app.py b/backend/app.py index 2684857..e4ee6a6 100644 --- a/backend/app.py +++ b/backend/app.py @@ -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, diff --git a/backend/schemas.py b/backend/schemas.py index 1317449..9a08c2b 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -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) diff --git a/frontend/app.js b/frontend/app.js index bb93cc7..a1a55d6 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -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) {

Start typing to search schools across England

${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) + ? `${escapeHtml(school.religious_denomination)}` + : ""; + + // Age range display + const ageRange = school.age_range + ? `Ages ${escapeHtml(school.age_range)}` + : ""; + + // Map container (only if coordinates available) + const hasCoords = school.latitude && school.longitude; + const mapContainer = hasCoords + ? `
` + : ""; + + return ` - `, - ) + `; + }) .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 = ` +
+
+

${escapeHtml(schoolName)}

+ +
+
+
+ `; + + 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: '© OpenStreetMap', + }).addTo(fullMap); + + const marker = L.marker([lat, lng]).addTo(fullMap); + marker.bindPopup(`${escapeHtml(schoolName)}`).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) { ? `${school.distance.toFixed(1)} mi` : ""; + // Religious denomination tag (only show if meaningful) + const faithTag = school.religious_denomination && + !["None", "Does not apply", ""].includes(school.religious_denomination) + ? `${escapeHtml(school.religious_denomination)}` + : ""; + + // Age range display + const ageRange = school.age_range + ? `Ages ${escapeHtml(school.age_range)}` + : ""; + + // Map container (only if coordinates available) + const hasCoords = school.latitude && school.longitude; + const mapContainer = hasCoords + ? `
` + : ""; + return `

${escapeHtml(school.school_name)}${distanceBadge}

${escapeHtml(school.local_authority || "")} ${escapeHtml(school.school_type || "")} + ${faithTag}
${escapeHtml(school.address || "")}
+ ${ageRange ? `
${ageRange}
` : ""}
${formatMetricValue(school.rwm_expected_pct, "rwm_expected_pct")}
@@ -612,6 +771,7 @@ function renderSchools(schools) {
Pupils
+ ${mapContainer}
`; }) @@ -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); }); diff --git a/frontend/index.html b/frontend/index.html index 76689cc..b845976 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -57,6 +57,9 @@ + + + diff --git a/frontend/styles.css b/frontend/styles.css index a60a2e1..a9c8c59 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -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;