Add list/map view toggle for location search results
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 56s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 56s
When searching by location, users can now toggle between list view (school cards grid) and a split map view showing: - Interactive map on left with all school markers - Scrollable school list on right - Blue marker for search location, default markers for schools - Clicking a marker highlights and scrolls to the corresponding card Mobile responsive with stacked layout on smaller screens. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
204
frontend/app.js
204
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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
}).addTo(resultsMapInstance);
|
||||
|
||||
// Create custom icon for search location (blue)
|
||||
const searchIcon = L.divIcon({
|
||||
className: "search-location-marker",
|
||||
html: `<svg viewBox="0 0 24 24" fill="#3498db" stroke="#2980b9" stroke-width="1" width="32" height="32">
|
||||
<circle cx="12" cy="12" r="8"/>
|
||||
</svg>`,
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16],
|
||||
});
|
||||
|
||||
// Add search location marker
|
||||
L.marker([lat, lng], { icon: searchIcon })
|
||||
.addTo(resultsMapInstance)
|
||||
.bindPopup(`<strong>Search location</strong><br>${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(`
|
||||
<strong>${escapeHtml(school.school_name)}</strong><br>
|
||||
${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", () => {
|
||||
|
||||
@@ -154,8 +154,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="schools-grid" id="schools-grid">
|
||||
<!-- School cards populated by JS -->
|
||||
<div class="view-toggle" id="view-toggle" style="display: none;">
|
||||
<button class="view-toggle-btn active" data-view="list">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||
<line x1="8" y1="6" x2="21" y2="6"/>
|
||||
<line x1="8" y1="12" x2="21" y2="12"/>
|
||||
<line x1="8" y1="18" x2="21" y2="18"/>
|
||||
<line x1="3" y1="6" x2="3.01" y2="6"/>
|
||||
<line x1="3" y1="12" x2="3.01" y2="12"/>
|
||||
<line x1="3" y1="18" x2="3.01" y2="18"/>
|
||||
</svg>
|
||||
List
|
||||
</button>
|
||||
<button class="view-toggle-btn" data-view="map">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||
<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>
|
||||
Map
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="results-container" id="results-container">
|
||||
<div class="results-map" id="results-map"></div>
|
||||
<div class="schools-grid" id="schools-grid">
|
||||
<!-- School cards populated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user