// 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.searchTimeout = null; this.suggestionsVisible = false; this.currentSuggestionIndex = -1; this.initializeSearchBar(); } 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 sidepanel this.createSidepanel(); // Store references this.toggleButton = toggleButton; this.searchVisible = false; // Bind events this.bindSearchEvents(); } createSidepanel() { // Create sidepanel container const sidepanel = document.createElement('div'); sidepanel.className = 'location-search-sidepanel fixed top-0 right-0 h-full w-96 bg-white shadow-2xl border-l transform translate-x-full transition-transform duration-300 ease-in-out z-50'; sidepanel.id = 'location-search-sidepanel'; sidepanel.innerHTML = `

Location Search

🔍

Search Your Visits

Find locations you've been to by searching for places, addresses, or business names.

`; // Add sidepanel to body document.body.appendChild(sidepanel); // Store references this.sidepanel = sidepanel; this.searchInput = document.getElementById('location-search-input'); this.searchButton = document.getElementById('location-search-submit'); this.clearButton = document.getElementById('location-search-clear'); this.closeButton = document.getElementById('location-search-close'); this.suggestionsContainer = document.getElementById('location-search-suggestions'); this.suggestionsPanel = document.getElementById('location-search-suggestions-panel'); this.resultsContainer = document.getElementById('location-search-results'); this.resultsPanel = document.getElementById('location-search-results-panel'); this.defaultPanel = document.getElementById('location-search-default'); } bindSearchEvents() { // Toggle sidepanel visibility this.toggleButton.addEventListener('click', () => { this.showSidepanel(); }); // Close sidepanel this.closeButton.addEventListener('click', () => { this.hideSidepanel(); }); // Search on button click this.searchButton.addEventListener('click', () => { this.performSearch(); }); // Search on Enter key this.searchInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') { if (this.suggestionsVisible && this.currentSuggestionIndex >= 0) { this.selectSuggestion(this.currentSuggestionIndex); } else { this.performSearch(); } } }); // Clear search this.clearButton.addEventListener('click', () => { this.clearSearch(); }); // Show clear button when input has content and handle real-time suggestions this.searchInput.addEventListener('input', (e) => { const query = e.target.value.trim(); if (query.length > 0) { this.clearButton.classList.remove('hidden'); this.debouncedSuggestionSearch(query); } else { this.clearButton.classList.add('hidden'); this.hideSuggestions(); this.showDefaultState(); } }); // Handle keyboard navigation for suggestions this.searchInput.addEventListener('keydown', (e) => { if (this.suggestionsVisible) { switch (e.key) { case 'ArrowDown': e.preventDefault(); this.navigateSuggestions(1); break; case 'ArrowUp': e.preventDefault(); this.navigateSuggestions(-1); break; case 'Escape': this.hideSuggestions(); this.showDefaultState(); break; } } }); // Close sidepanel on Escape key document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && this.searchVisible) { this.hideSidepanel(); } }); // Close sidepanel when clicking outside document.addEventListener('click', (e) => { if (this.searchVisible && !e.target.closest('.location-search-sidepanel') && !e.target.closest('#location-search-toggle')) { this.hideSidepanel(); } }); } 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() { // Hide other panels and show results with loading this.defaultPanel.classList.add('hidden'); this.suggestionsPanel.classList.add('hidden'); this.resultsPanel.classList.remove('hidden'); this.resultsContainer.innerHTML = `
Searching for "${this.currentSearchQuery}"...
`; } showError(message) { // Hide other panels and show results with error this.defaultPanel.classList.add('hidden'); this.suggestionsPanel.classList.add('hidden'); this.resultsPanel.classList.remove('hidden'); this.resultsContainer.innerHTML = `
⚠️
Search Failed
${message}
`; } displaySearchResults(data) { // Hide other panels and show results this.defaultPanel.classList.add('hidden'); this.suggestionsPanel.classList.add('hidden'); this.resultsPanel.classList.remove('hidden'); if (!data.locations || data.locations.length === 0) { this.resultsContainer.innerHTML = `
📍
No visits found
No visits found for "${this.currentSearchQuery}"
`; 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; // 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]; // Group visits by year const visitsByYear = this.groupVisitsByYear(location.visits); return `
${this.escapeHtml(location.place_name)}
${this.escapeHtml(location.address || '')}
${location.total_visits} visit(s)
first ${this.formatDateShort(firstVisit.date)}, last ${this.formatDateShort(lastVisit.date)}
${Object.entries(visitsByYear).map(([year, yearVisits]) => `
${year} ${yearVisits.length} visits
`).join('')}
`; } groupVisitsByYear(visits) { const groups = {}; visits.forEach(visit => { const year = new Date(visit.date).getFullYear().toString(); if (!groups[year]) { groups[year] = []; } groups[year].push(visit); }); // Sort years descending (most recent first) const sortedGroups = {}; Object.keys(groups) .sort((a, b) => parseInt(b) - parseInt(a)) .forEach(year => { sortedGroups[year] = groups[year]; }); return sortedGroups; } formatDateShort(dateString) { const date = new Date(dateString); return date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' }); } bindResultEvents() { // Bind click events to year toggles const yearToggles = this.resultsContainer.querySelectorAll('.year-toggle'); yearToggles.forEach(toggle => { toggle.addEventListener('click', (e) => { e.stopPropagation(); const locationIndex = parseInt(toggle.dataset.locationIndex); const year = toggle.dataset.year; this.toggleYear(locationIndex, year, toggle); }); }); // Bind click events to individual visits const visitResults = this.resultsContainer.querySelectorAll('.visit-item'); visitResults.forEach(visit => { visit.addEventListener('click', (e) => { e.stopPropagation(); // Prevent triggering other clicks const locationIndex = parseInt(visit.dataset.locationIndex); const visitIndex = parseInt(visit.dataset.visitIndex); this.focusOnVisit(this.searchResults[locationIndex], visitIndex); }); }); } toggleYear(locationIndex, year, toggleElement) { const yearVisitsContainer = document.getElementById(`year-${locationIndex}-${year}`); const arrow = toggleElement.querySelector('.year-arrow'); if (yearVisitsContainer.classList.contains('hidden')) { // Show visits yearVisitsContainer.classList.remove('hidden'); arrow.style.transform = 'rotate(90deg)'; arrow.textContent = '▼'; } else { // Hide visits yearVisitsContainer.classList.add('hidden'); arrow.style.transform = 'rotate(0deg)'; arrow.textContent = '▶'; } } 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(); } focusOnVisit(location, visitIndex) { const visit = location.visits[visitIndex]; if (!visit) return; // Navigate to the visit coordinates (more precise than location coordinates) const [lat, lon] = visit.coordinates || location.coordinates; this.map.setView([lat, lon], 18); // Higher zoom for individual visit // Parse the visit timestamp to create a time filter const visitDate = new Date(visit.date); const startTime = new Date(visitDate.getTime() - (2 * 60 * 60 * 1000)); // 2 hours before const endTime = new Date(visitDate.getTime() + (2 * 60 * 60 * 1000)); // 2 hours after // Emit custom event for time filtering that other parts of the app can listen to const timeFilterEvent = new CustomEvent('locationSearch:timeFilter', { detail: { startTime: startTime.toISOString(), endTime: endTime.toISOString(), visitDate: visit.date, location: location.place_name, coordinates: [lat, lon] } }); document.dispatchEvent(timeFilterEvent); // Create a special marker for the specific visit this.addVisitMarker(lat, lon, visit, location); // Show visit details in a popup or notification this.showVisitDetails(visit, location); // DON'T hide results - keep sidebar open // this.hideResults(); } addVisitMarker(lat, lon, visit, location) { // Remove existing visit marker if any if (this.visitMarker) { this.map.removeLayer(this.visitMarker); } // Create a highlighted marker for the specific visit this.visitMarker = L.circleMarker([lat, lon], { radius: 12, fillColor: '#22c55e', // Green color to distinguish from search results color: '#ffffff', weight: 3, opacity: 1, fillOpacity: 0.9 }); const popupContent = `
${this.escapeHtml(location.place_name)}
${this.escapeHtml(location.address || '')}
Visit Details:
${this.formatDateTime(visit.date)}
Duration: ${visit.duration_estimate}
`; this.visitMarker.bindPopup(popupContent, { closeButton: true, autoClose: false, // Don't auto-close when clicking elsewhere closeOnEscapeKey: true, // Allow closing with Escape key closeOnClick: false // Don't close when clicking on map }); this.visitMarker.addTo(this.map); this.visitMarker.openPopup(); // Add event listener to clean up when popup is closed this.visitMarker.on('popupclose', () => { if (this.visitMarker) { this.map.removeLayer(this.visitMarker); this.visitMarker = null; } }); // Store reference for manual cleanup if needed this.currentVisitMarker = this.visitMarker; } showVisitDetails(visit, location) { // Remove any existing notification const existingNotification = document.querySelector('.visit-navigation-notification'); if (existingNotification) { existingNotification.remove(); } // Create a persistent notification showing visit details const notification = document.createElement('div'); notification.className = 'visit-navigation-notification fixed top-4 right-4 z-40 bg-green-50 border border-green-200 rounded-lg p-4 shadow-lg max-w-sm'; notification.innerHTML = `
📍
Viewing visit
${this.escapeHtml(location.place_name)}
${this.formatDateTime(visit.date)} • ${visit.duration_estimate}
`; document.body.appendChild(notification); // Auto-remove notification after 10 seconds (longer duration) setTimeout(() => { if (notification.parentNode) { notification.remove(); } }, 10000); } clearSearch() { this.searchInput.value = ''; this.clearButton.classList.add('hidden'); this.hideResults(); this.clearSearchMarkers(); this.clearVisitMarker(); this.currentSearchQuery = ''; } clearVisitMarker() { if (this.visitMarker) { this.map.removeLayer(this.visitMarker); this.visitMarker = null; } if (this.currentVisitMarker) { this.map.removeLayer(this.currentVisitMarker); this.currentVisitMarker = null; } // Remove any visit notifications const existingNotification = document.querySelector('.visit-navigation-notification'); if (existingNotification) { existingNotification.remove(); } } showSidepanel() { this.sidepanel.classList.remove('translate-x-full'); this.searchVisible = true; // Focus the search input for immediate typing setTimeout(() => { this.searchInput.focus(); }, 300); // Wait for animation to complete } hideSidepanel() { this.sidepanel.classList.add('translate-x-full'); this.searchVisible = false; this.clearSearch(); this.showDefaultState(); } showDefaultState() { this.defaultPanel.classList.remove('hidden'); this.suggestionsPanel.classList.add('hidden'); this.resultsPanel.classList.add('hidden'); } clearSearchMarkers() { if (this.searchMarkersLayer) { this.map.removeLayer(this.searchMarkersLayer); this.searchMarkersLayer = null; } } hideResults() { this.resultsContainer.classList.add('hidden'); } // Suggestion-related methods debouncedSuggestionSearch(query) { // Clear existing timeout if (this.searchTimeout) { clearTimeout(this.searchTimeout); } // Set new timeout for debounced search this.searchTimeout = setTimeout(() => { this.performSuggestionSearch(query); }, 300); // 300ms debounce delay } async performSuggestionSearch(query) { if (query.length < 2) { this.hideSuggestions(); return; } // Show loading state for suggestions this.showSuggestionsLoading(); try { const response = await fetch(`/api/v1/locations/suggestions?q=${encodeURIComponent(query)}`, { method: 'GET', headers: { 'Authorization': `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' } }); if (!response.ok) { throw new Error(`Suggestions failed: ${response.status}`); } const data = await response.json(); this.displaySuggestions(data.suggestions || []); } catch (error) { console.error('Suggestion search error:', error); this.hideSuggestions(); } } showSuggestionsLoading() { // Hide other panels and show suggestions with loading this.defaultPanel.classList.add('hidden'); this.resultsPanel.classList.add('hidden'); this.suggestionsPanel.classList.remove('hidden'); this.suggestionsContainer.innerHTML = `
Finding suggestions...
`; } displaySuggestions(suggestions) { if (!suggestions.length) { this.hideSuggestions(); return; } // Hide other panels and show suggestions this.defaultPanel.classList.add('hidden'); this.resultsPanel.classList.add('hidden'); this.suggestionsPanel.classList.remove('hidden'); // Build suggestions HTML let suggestionsHtml = ''; suggestions.forEach((suggestion, index) => { const isActive = index === this.currentSuggestionIndex; suggestionsHtml += `
${this.escapeHtml(suggestion.name)}
${this.escapeHtml(suggestion.address || '')}
${suggestion.type}
`; }); this.suggestionsContainer.innerHTML = suggestionsHtml; this.suggestionsVisible = true; this.suggestions = suggestions; // Bind click events to suggestions this.bindSuggestionEvents(); } bindSuggestionEvents() { const suggestionItems = this.suggestionsContainer.querySelectorAll('.suggestion-item'); suggestionItems.forEach(item => { item.addEventListener('click', (e) => { const index = parseInt(e.currentTarget.dataset.suggestionIndex); this.selectSuggestion(index); }); }); } navigateSuggestions(direction) { if (!this.suggestions || !this.suggestions.length) return; const maxIndex = this.suggestions.length - 1; if (direction > 0) { // Arrow down this.currentSuggestionIndex = this.currentSuggestionIndex < maxIndex ? this.currentSuggestionIndex + 1 : 0; } else { // Arrow up this.currentSuggestionIndex = this.currentSuggestionIndex > 0 ? this.currentSuggestionIndex - 1 : maxIndex; } this.highlightActiveSuggestion(); } highlightActiveSuggestion() { const suggestionItems = this.suggestionsContainer.querySelectorAll('.suggestion-item'); suggestionItems.forEach((item, index) => { if (index === this.currentSuggestionIndex) { item.classList.add('bg-blue-50', 'text-blue-700'); item.classList.remove('bg-gray-50'); } else { item.classList.remove('bg-blue-50', 'text-blue-700'); item.classList.add('bg-gray-50'); } }); } selectSuggestion(index) { if (!this.suggestions || index < 0 || index >= this.suggestions.length) return; const suggestion = this.suggestions[index]; this.searchInput.value = suggestion.name; this.hideSuggestions(); this.showSearchLoading(suggestion.name); this.performCoordinateSearch(suggestion); // Use coordinate-based search for selected suggestion } showSearchLoading(locationName) { // Hide other panels and show loading for search results this.defaultPanel.classList.add('hidden'); this.suggestionsPanel.classList.add('hidden'); this.resultsPanel.classList.remove('hidden'); this.resultsContainer.innerHTML = `
Searching visits to
${this.escapeHtml(locationName)}
`; } async performCoordinateSearch(suggestion) { this.currentSearchQuery = suggestion.name; // Loading state already shown by showSearchLoading try { const params = new URLSearchParams({ lat: suggestion.coordinates[0], lon: suggestion.coordinates[1], name: suggestion.name, address: suggestion.address || '' }); const response = await fetch(`/api/v1/locations?${params}`, { method: 'GET', headers: { 'Authorization': `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' } }); if (!response.ok) { throw new Error(`Coordinate search failed: ${response.status} ${response.statusText}`); } const data = await response.json(); this.displaySearchResults(data); } catch (error) { console.error('Coordinate search error:', error); this.showError('Failed to search locations. Please try again.'); } } hideSuggestions() { this.suggestionsPanel.classList.add('hidden'); this.suggestionsVisible = false; this.currentSuggestionIndex = -1; this.suggestions = []; if (this.searchTimeout) { clearTimeout(this.searchTimeout); this.searchTimeout = null; } } // 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 };