// Location search functionality for the map class LocationSearch { constructor(map, apiKey) { this.map = map; this.apiKey = apiKey; this.searchResults = []; this.searchMarkersLayer = null; this.currentSearchQuery = ''; this.initializeSearchBar(); this.initializeSearchResults(); } initializeSearchBar() { // Create search toggle button using Leaflet control (positioned below settings button) const SearchToggleControl = L.Control.extend({ onAdd: function(map) { const button = L.DomUtil.create('button', 'location-search-toggle'); button.innerHTML = '🔍'; button.style.width = '48px'; button.style.height = '48px'; button.style.border = 'none'; button.style.cursor = 'pointer'; button.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)'; button.style.backgroundColor = 'white'; button.style.borderRadius = '4px'; button.style.padding = '0'; button.style.fontSize = '18px'; button.style.marginTop = '10px'; // Space below settings button button.title = 'Search locations'; button.id = 'location-search-toggle'; return button; } }); // Add the search toggle control to the map this.map.addControl(new SearchToggleControl({ position: 'topright' })); // Get reference to the created button const toggleButton = document.getElementById('location-search-toggle'); // Create search container (initially hidden) // Position it to the left of the search toggle button using fixed positioning const searchContainer = document.createElement('div'); searchContainer.className = 'location-search-container fixed z-50 w-80 hidden bg-white rounded-lg shadow-xl border p-2'; searchContainer.id = 'location-search-container'; // Create search input const searchInput = document.createElement('input'); searchInput.type = 'text'; searchInput.placeholder = 'Search locations'; searchInput.className = 'input input-bordered w-full text-sm bg-white shadow-lg'; searchInput.id = 'location-search-input'; // Create search button const searchButton = document.createElement('button'); searchButton.innerHTML = '🔍'; searchButton.className = 'btn btn-primary btn-sm absolute right-2 top-1/2 transform -translate-y-1/2'; searchButton.type = 'button'; // Create clear button const clearButton = document.createElement('button'); clearButton.innerHTML = '✕'; clearButton.className = 'btn btn-ghost btn-xs absolute right-12 top-1/2 transform -translate-y-1/2 hidden'; clearButton.id = 'location-search-clear'; // Assemble search bar const searchWrapper = document.createElement('div'); searchWrapper.className = 'relative'; searchWrapper.appendChild(searchInput); searchWrapper.appendChild(clearButton); searchWrapper.appendChild(searchButton); searchContainer.appendChild(searchWrapper); // Add search container to map container const mapContainer = document.getElementById('map'); mapContainer.appendChild(searchContainer); // Store references this.toggleButton = toggleButton; this.searchContainer = searchContainer; this.searchInput = searchInput; this.searchButton = searchButton; this.clearButton = clearButton; this.searchVisible = false; // Bind events this.bindSearchEvents(); } initializeSearchResults() { // Create results container (positioned below search container) const resultsContainer = document.createElement('div'); resultsContainer.className = 'location-search-results fixed z-40 w-80 max-h-96 overflow-y-auto bg-white rounded-lg shadow-xl border hidden'; resultsContainer.id = 'location-search-results'; const mapContainer = document.getElementById('map'); mapContainer.appendChild(resultsContainer); this.resultsContainer = resultsContainer; } bindSearchEvents() { // Toggle search bar visibility this.toggleButton.addEventListener('click', () => { this.toggleSearchBar(); }); // Search on button click this.searchButton.addEventListener('click', () => { this.performSearch(); }); // Search on Enter key this.searchInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') { this.performSearch(); } }); // Clear search this.clearButton.addEventListener('click', () => { this.clearSearch(); }); // Show clear button when input has content this.searchInput.addEventListener('input', (e) => { if (e.target.value.length > 0) { this.clearButton.classList.remove('hidden'); } else { this.clearButton.classList.add('hidden'); } }); // Hide results and search bar when clicking outside document.addEventListener('click', (e) => { if (!e.target.closest('.location-search-container') && !e.target.closest('.location-search-results') && !e.target.closest('#location-search-toggle')) { this.hideResults(); if (this.searchVisible) { this.hideSearchBar(); } } }); // Close search bar on Escape key document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && this.searchVisible) { this.hideSearchBar(); } }); } async performSearch() { const query = this.searchInput.value.trim(); if (!query) return; this.currentSearchQuery = query; this.showLoading(); try { const response = await fetch(`/api/v1/locations?q=${encodeURIComponent(query)}`, { method: 'GET', headers: { 'Authorization': `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' } }); if (!response.ok) { throw new Error(`Search failed: ${response.status} ${response.statusText}`); } const data = await response.json(); this.displaySearchResults(data); } catch (error) { console.error('Location search error:', error); this.showError('Failed to search locations. Please try again.'); } } showLoading() { this.resultsContainer.innerHTML = `
Searching for "${this.currentSearchQuery}"...
`; this.resultsContainer.classList.remove('hidden'); } showError(message) { // Position results container below search container using viewport coordinates const searchRect = this.searchContainer.getBoundingClientRect(); const resultsTop = searchRect.bottom + 5; const resultsRight = window.innerWidth - searchRect.left; this.resultsContainer.style.top = resultsTop + 'px'; this.resultsContainer.style.right = resultsRight + 'px'; this.resultsContainer.innerHTML = `
${message}
`; this.resultsContainer.classList.remove('hidden'); } displaySearchResults(data) { // Position results container below search container using viewport coordinates const searchRect = this.searchContainer.getBoundingClientRect(); const resultsTop = searchRect.bottom + 5; // 5px gap below search container const resultsRight = window.innerWidth - searchRect.left; // Align with left edge of search container this.resultsContainer.style.top = resultsTop + 'px'; this.resultsContainer.style.right = resultsRight + 'px'; if (!data.locations || data.locations.length === 0) { this.resultsContainer.innerHTML = `
No visits found for "${this.currentSearchQuery}"
`; this.resultsContainer.classList.remove('hidden'); return; } this.searchResults = data.locations; this.clearSearchMarkers(); let resultsHtml = `
Found ${data.total_locations} location(s) for "${this.currentSearchQuery}"
`; data.locations.forEach((location, index) => { resultsHtml += this.buildLocationResultHtml(location, index); }); this.resultsContainer.innerHTML = resultsHtml; this.resultsContainer.classList.remove('hidden'); // Add markers to map this.addSearchMarkersToMap(data.locations); // Bind result interaction events this.bindResultEvents(); } buildLocationResultHtml(location, index) { const firstVisit = location.visits[0]; const lastVisit = location.visits[location.visits.length - 1]; return `
${this.escapeHtml(location.place_name)}
${this.escapeHtml(location.address || '')}
${location.total_visits} visit(s)
${this.formatDate(firstVisit.date)} - ${this.formatDate(lastVisit.date)}
${location.visits.slice(0, 5).map(visit => `
📍 ${this.formatDateTime(visit.date)} (${visit.distance_meters}m away)
`).join('')} ${location.visits.length > 5 ? `
+ ${location.visits.length - 5} more visits
` : ''}
`; } bindResultEvents() { const locationResults = this.resultsContainer.querySelectorAll('.location-result'); locationResults.forEach(result => { result.addEventListener('click', (e) => { const index = parseInt(e.currentTarget.dataset.locationIndex); this.focusOnLocation(this.searchResults[index]); }); }); } addSearchMarkersToMap(locations) { if (this.searchMarkersLayer) { this.map.removeLayer(this.searchMarkersLayer); } this.searchMarkersLayer = L.layerGroup(); locations.forEach(location => { const [lat, lon] = location.coordinates; // Create custom search result marker const marker = L.circleMarker([lat, lon], { radius: 8, fillColor: '#ff6b35', color: '#ffffff', weight: 2, opacity: 1, fillOpacity: 0.8 }); // Add popup with location info const popupContent = `
${this.escapeHtml(location.place_name)}
${this.escapeHtml(location.address || '')}
${location.total_visits} visit(s)
`; marker.bindPopup(popupContent); this.searchMarkersLayer.addLayer(marker); }); this.searchMarkersLayer.addTo(this.map); } focusOnLocation(location) { const [lat, lon] = location.coordinates; this.map.setView([lat, lon], 16); // Flash the marker const markers = this.searchMarkersLayer.getLayers(); const targetMarker = markers.find(marker => { const latLng = marker.getLatLng(); return Math.abs(latLng.lat - lat) < 0.0001 && Math.abs(latLng.lng - lon) < 0.0001; }); if (targetMarker) { targetMarker.openPopup(); } this.hideResults(); } clearSearch() { this.searchInput.value = ''; this.clearButton.classList.add('hidden'); this.hideResults(); this.clearSearchMarkers(); this.currentSearchQuery = ''; } toggleSearchBar() { if (this.searchVisible) { this.hideSearchBar(); } else { this.showSearchBar(); } } showSearchBar() { // Calculate position relative to the toggle button using viewport coordinates const buttonRect = this.toggleButton.getBoundingClientRect(); // Position search container to the left of the button, aligned vertically // Using fixed positioning relative to viewport const searchTop = buttonRect.top; // Same vertical position as button (viewport coordinates) const searchRight = window.innerWidth - buttonRect.left + 10; // 10px gap to the left of button // Debug logging to see actual values console.log('Button rect:', buttonRect); console.log('Window width:', window.innerWidth); console.log('Calculated top:', searchTop); console.log('Calculated right:', searchRight); this.searchContainer.style.top = searchTop + 'px'; this.searchContainer.style.right = searchRight + 'px'; this.searchContainer.classList.remove('hidden'); this.searchVisible = true; // Focus the search input for immediate typing setTimeout(() => { this.searchInput.focus(); }, 100); } hideSearchBar() { this.searchContainer.classList.add('hidden'); this.hideResults(); this.clearSearch(); this.searchVisible = false; } clearSearchMarkers() { if (this.searchMarkersLayer) { this.map.removeLayer(this.searchMarkersLayer); this.searchMarkersLayer = null; } } hideResults() { this.resultsContainer.classList.add('hidden'); } // Utility methods escapeHtml(text) { const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; return text ? text.replace(/[&<>"']/g, m => map[m]) : ''; } formatDate(dateString) { return new Date(dateString).toLocaleDateString(); } formatDateTime(dateString) { return new Date(dateString).toLocaleDateString() + ' ' + new Date(dateString).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); } } export { LocationSearch };