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 `
${escapeHtml(school.school_name)}
${escapeHtml(school.local_authority || "")}
${escapeHtml(school.school_type || "")}
+ ${faithTag}
${escapeHtml(school.address || "")}
+ ${ageRange ? `
${ageRange}
` : ""}
${formatMetricValue(school.rwm_expected_pct, "rwm_expected_pct")}
@@ -490,15 +512,21 @@ function renderFeaturedSchools(schools) {
Pupils
+ ${mapContainer}
- `,
- )
+ `;
+ })
.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 = `
+
+ `;
+
+ 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;