diff --git a/backend/app.py b/backend/app.py index d012d8e..6c932c6 100644 --- a/backend/app.py +++ b/backend/app.py @@ -350,7 +350,7 @@ async def get_schools( "page": page, "page_size": page_size, "total_pages": (total + page_size - 1) // page_size if page_size > 0 else 0, - "search_location": {"postcode": postcode, "radius": radius} + "search_location": {"postcode": postcode, "radius": radius, "lat": search_coords[0], "lng": search_coords[1]} if search_coords else None, } diff --git a/frontend/app.js b/frontend/app.js index d51b305..c7e3181 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -22,7 +22,9 @@ const state = { active: false, postcode: null, radius: 5, + coords: null, // Lat/lng of search location }, + resultsView: "list", // "list" or "map" loading: { schools: false, filters: false, @@ -36,6 +38,7 @@ const state = { let comparisonChart = null; let schoolDetailChart = null; let modalMap = null; +let resultsMapInstance = null; // Chart colors const CHART_COLORS = [ @@ -189,6 +192,11 @@ const elements = { radiusSelect: document.getElementById("radius-select"), locationSearchBtn: document.getElementById("location-search-btn"), typeFilterLocation: document.getElementById("type-filter-location"), + // Results view + viewToggle: document.getElementById("view-toggle"), + viewToggleBtns: document.querySelectorAll(".view-toggle-btn"), + resultsContainer: document.getElementById("results-container"), + resultsMap: document.getElementById("results-map"), // Schools grid schoolsGrid: document.getElementById("schools-grid"), compareSearch: document.getElementById("compare-search"), @@ -522,10 +530,26 @@ async function loadSchools() { state.pagination.totalPages = data.total_pages; state.isShowingFeatured = false; + // Store search coordinates if available + if (data.search_location && data.search_location.lat && data.search_location.lng) { + state.locationSearch.coords = { + lat: data.search_location.lat, + lng: data.search_location.lng, + }; + } + // Show location info banner if location search is active updateLocationInfoBanner(data.search_location); renderSchools(state.schools); + + // Update view toggle visibility + updateViewToggle(); + + // If map view is active, reinitialize the map with new results + if (state.resultsView === "map" && state.locationSearch.active) { + initializeResultsMap(state.schools); + } } async function loadFeaturedSchools() { @@ -548,6 +572,9 @@ async function loadFeaturedSchools() { state.isShowingFeatured = true; renderFeaturedSchools(state.schools); + + // Hide view toggle for featured schools + updateViewToggle(); } function updateLocationInfoBanner(searchLocation) { @@ -908,6 +935,168 @@ function openMapModal(lat, lng, schoolName) { document.addEventListener("keydown", escHandler); } +/** + * Initialize the results map for location search + */ +function initializeResultsMap(schools) { + // Check if Leaflet is loaded + if (typeof L === "undefined") { + console.warn("Leaflet not loaded, skipping results map initialization"); + return; + } + + // Destroy existing map if any + if (resultsMapInstance) { + try { + resultsMapInstance.remove(); + } catch (e) { + // Ignore cleanup errors + } + resultsMapInstance = null; + } + + // Need search coords to center the map + if (!state.locationSearch.coords) { + console.warn("No search coordinates available for results map"); + return; + } + + const { lat, lng } = state.locationSearch.coords; + + // Ensure container has dimensions + setTimeout(() => { + const mapContainer = elements.resultsMap; + if (!mapContainer || mapContainer.offsetWidth === 0 || mapContainer.offsetHeight === 0) { + console.warn("Results map container has no dimensions"); + return; + } + + // Create map centered on search location + resultsMapInstance = L.map(mapContainer, { + center: [lat, lng], + zoom: 14, + scrollWheelZoom: true, + }); + + // Add tile layer + L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { + maxZoom: 19, + attribution: '© OpenStreetMap', + }).addTo(resultsMapInstance); + + // Create custom icon for search location (blue) + const searchIcon = L.divIcon({ + className: "search-location-marker", + html: ` + + `, + iconSize: [32, 32], + iconAnchor: [16, 16], + }); + + // Add search location marker + L.marker([lat, lng], { icon: searchIcon }) + .addTo(resultsMapInstance) + .bindPopup(`Search location
${state.locationSearch.postcode}`); + + // Add school markers + const bounds = L.latLngBounds([[lat, lng]]); + schools.forEach((school) => { + if (school.latitude && school.longitude) { + const marker = L.marker([school.latitude, school.longitude]) + .addTo(resultsMapInstance) + .bindPopup(` + ${escapeHtml(school.school_name)}
+ ${school.distance !== undefined ? school.distance.toFixed(1) + " miles away" : ""} + `); + + // Click handler to highlight card + marker.on("click", () => { + highlightSchoolCard(school.urn); + }); + + bounds.extend([school.latitude, school.longitude]); + } + }); + + // Fit bounds to show all markers with padding + if (schools.length > 0) { + resultsMapInstance.fitBounds(bounds, { padding: [30, 30] }); + } + }, 100); +} + +/** + * Highlight a school card and scroll it into view + */ +function highlightSchoolCard(urn) { + // Remove highlight from all cards + document.querySelectorAll(".school-card").forEach((card) => { + card.classList.remove("highlighted"); + }); + + // Add highlight to selected card + const card = document.querySelector(`.school-card[data-urn="${urn}"]`); + if (card) { + card.classList.add("highlighted"); + card.scrollIntoView({ behavior: "smooth", block: "center" }); + } +} + +/** + * Destroy the results map instance + */ +function destroyResultsMap() { + if (resultsMapInstance) { + try { + resultsMapInstance.remove(); + } catch (e) { + // Ignore cleanup errors + } + resultsMapInstance = null; + } +} + +/** + * Update the view toggle visibility and state + */ +function updateViewToggle() { + // Only show toggle for location search results + if (state.locationSearch.active && state.schools.length > 0) { + elements.viewToggle.style.display = "flex"; + } else { + elements.viewToggle.style.display = "none"; + // Reset to list view when hiding toggle + if (state.resultsView === "map") { + setResultsView("list"); + } + } +} + +/** + * Set the results view mode (list or map) + */ +function setResultsView(view) { + state.resultsView = view; + + // Update toggle button states + elements.viewToggleBtns.forEach((btn) => { + btn.classList.toggle("active", btn.dataset.view === view); + }); + + // Update container class + if (view === "map") { + elements.resultsContainer.classList.add("map-view"); + // Initialize map if location search is active + if (state.locationSearch.active) { + initializeResultsMap(state.schools); + } + } else { + elements.resultsContainer.classList.remove("map-view"); + destroyResultsMap(); + } +} + // ============================================================================= // RENDER FUNCTIONS // ============================================================================= @@ -1709,11 +1898,14 @@ function setupEventListeners() { // Clear the inactive mode's state if (mode === "name") { // Clear location search state - state.locationSearch = { active: false, postcode: null, radius: 5 }; + state.locationSearch = { active: false, postcode: null, radius: 5, coords: null }; elements.postcodeSearch.value = ""; elements.radiusSelect.value = "5"; elements.typeFilterLocation.value = ""; updateLocationInfoBanner(null); + // Reset to list view and hide toggle + setResultsView("list"); + updateViewToggle(); } else { // Clear name search state elements.schoolSearch.value = ""; @@ -1726,6 +1918,16 @@ function setupEventListeners() { }); }); + // View toggle (list/map) + elements.viewToggleBtns.forEach((btn) => { + btn.addEventListener("click", () => { + const view = btn.dataset.view; + if (view !== state.resultsView) { + setResultsView(view); + } + }); + }); + // Name search and filters let searchTimeout; elements.schoolSearch.addEventListener("input", () => { diff --git a/frontend/index.html b/frontend/index.html index fccf816..8992dcf 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -154,8 +154,32 @@ -
- + + +
+
+
+ +
diff --git a/frontend/styles.css b/frontend/styles.css index eaad7bf..8aba774 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -400,6 +400,108 @@ body { border-color: var(--accent-teal); } +/* View Toggle */ +.view-toggle { + display: flex; + gap: 0.5rem; + justify-content: center; + margin-bottom: 1.5rem; +} + +.view-toggle-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.6rem 1rem; + font-size: 0.9rem; + font-family: inherit; + font-weight: 500; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--bg-card); + color: var(--text-muted); + cursor: pointer; + transition: var(--transition); +} + +.view-toggle-btn:hover { + color: var(--text-primary); + border-color: var(--text-muted); +} + +.view-toggle-btn.active { + background: var(--accent-teal); + color: white; + border-color: var(--accent-teal); +} + +.view-toggle-btn svg { + flex-shrink: 0; +} + +/* Results Container */ +.results-container { + display: block; +} + +.results-container .results-map { + display: none; +} + +.results-container.map-view { + display: grid; + grid-template-columns: 1fr 400px; + gap: 1.5rem; + min-height: 600px; +} + +.results-container.map-view .results-map { + display: block; + border-radius: var(--radius-lg); + overflow: hidden; + border: 1px solid var(--border-color); + min-height: 600px; +} + +.results-container.map-view .schools-grid { + display: flex; + flex-direction: column; + gap: 1rem; + overflow-y: auto; + max-height: 600px; + padding-right: 0.5rem; +} + +.results-container.map-view .schools-grid::-webkit-scrollbar { + width: 6px; +} + +.results-container.map-view .schools-grid::-webkit-scrollbar-track { + background: var(--bg-main); + border-radius: 3px; +} + +.results-container.map-view .schools-grid::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 3px; +} + +.results-container.map-view .schools-grid::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} + +/* Highlighted card in map view */ +.school-card.highlighted { + border-color: var(--accent-teal); + box-shadow: 0 0 0 2px rgba(45, 125, 125, 0.2); +} + +/* Search location marker on map */ +.search-location-marker { + background: transparent; +} + /* Schools Grid */ .schools-grid { display: grid; @@ -669,6 +771,21 @@ body { .map-modal-content { height: 400px; } + + .results-container.map-view { + grid-template-columns: 1fr; + grid-template-rows: 350px auto; + min-height: auto; + } + + .results-container.map-view .results-map { + min-height: 350px; + } + + .results-container.map-view .schools-grid { + max-height: none; + overflow-y: visible; + } } /* Section Titles */