/** * Search Manager * Manages location search functionality for Maps V2 */ import { LocationSearchService } from '../services/location_search_service.js' export class SearchManager { constructor(map, apiKey) { this.map = map this.service = new LocationSearchService(apiKey) this.searchInput = null this.resultsContainer = null this.debounceTimer = null this.debounceDelay = 300 // ms this.currentMarker = null this.currentVisitsData = null // Store visits data for click handling } /** * Initialize search manager with DOM elements * @param {HTMLInputElement} searchInput - Search input element * @param {HTMLElement} resultsContainer - Container for search results */ initialize(searchInput, resultsContainer) { this.searchInput = searchInput this.resultsContainer = resultsContainer if (!this.searchInput || !this.resultsContainer) { console.warn('SearchManager: Missing required DOM elements') return } this.attachEventListeners() } /** * Attach event listeners to search input */ attachEventListeners() { // Input event with debouncing this.searchInput.addEventListener('input', (e) => { this.handleSearchInput(e.target.value) }) // Prevent results from hiding when clicking inside results container this.resultsContainer.addEventListener('mousedown', (e) => { e.preventDefault() // Prevent blur event on search input }) // Clear results when clicking outside document.addEventListener('click', (e) => { if (!this.searchInput.contains(e.target) && !this.resultsContainer.contains(e.target)) { // Delay to allow animations to complete setTimeout(() => { this.clearResults() }, 100) } }) // Handle Enter key this.searchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault() const firstResult = this.resultsContainer.querySelector('.search-result-item') if (firstResult) { firstResult.click() } } }) } /** * Handle search input with debouncing * @param {string} query - Search query */ handleSearchInput(query) { clearTimeout(this.debounceTimer) if (!query || query.length < 2) { this.clearResults() return } this.debounceTimer = setTimeout(async () => { try { this.showLoading() const suggestions = await this.service.fetchSuggestions(query) this.displayResults(suggestions) } catch (error) { this.showError('Failed to fetch suggestions') console.error('SearchManager: Search error:', error) } }, this.debounceDelay) } /** * Display search results * @param {Array} suggestions - Array of location suggestions */ displayResults(suggestions) { this.clearResults() if (!suggestions || suggestions.length === 0) { this.showNoResults() return } suggestions.forEach(suggestion => { const resultItem = this.createResultItem(suggestion) this.resultsContainer.appendChild(resultItem) }) this.resultsContainer.classList.remove('hidden') } /** * Create a result item element * @param {Object} suggestion - Location suggestion * @returns {HTMLElement} Result item element */ createResultItem(suggestion) { const item = document.createElement('div') item.className = 'search-result-item p-3 hover:bg-base-200 cursor-pointer rounded-lg transition-colors' item.setAttribute('data-lat', suggestion.lat) item.setAttribute('data-lon', suggestion.lon) const name = document.createElement('div') name.className = 'font-medium text-sm' name.textContent = suggestion.name || 'Unknown location' if (suggestion.address) { const address = document.createElement('div') address.className = 'text-xs text-base-content/60 mt-1' address.textContent = suggestion.address item.appendChild(name) item.appendChild(address) } else { item.appendChild(name) } item.addEventListener('click', () => { this.handleResultClick(suggestion) }) return item } /** * Handle click on search result * @param {Object} location - Selected location */ async handleResultClick(location) { // Fly to location on map this.map.flyTo({ center: [location.lon, location.lat], zoom: 15, duration: 1000 }) // Add temporary marker this.addSearchMarker(location.lon, location.lat) // Update search input if (this.searchInput) { this.searchInput.value = location.name || '' } // Show loading state in results this.showVisitsLoading(location.name) // Search for visits at this location try { const visitsData = await this.service.searchVisits({ lat: location.lat, lon: location.lon, name: location.name, address: location.address || '' }) // Display visits results this.displayVisitsResults(visitsData, location) } catch (error) { console.error('SearchManager: Failed to fetch visits:', error) this.showError('Failed to load visits for this location') } // Dispatch custom event for other components this.dispatchSearchEvent(location) } /** * Add a temporary marker at search location * @param {number} lon - Longitude * @param {number} lat - Latitude */ addSearchMarker(lon, lat) { // Remove existing marker if (this.currentMarker) { this.currentMarker.remove() } // Create marker element const el = document.createElement('div') el.className = 'search-marker' el.style.cssText = ` width: 30px; height: 30px; background-color: #3b82f6; border: 3px solid white; border-radius: 50%; box-shadow: 0 2px 4px rgba(0,0,0,0.3); cursor: pointer; ` // Add marker to map (MapLibre GL style) if (this.map.getSource) { // Use MapLibre marker const maplibregl = window.maplibregl if (maplibregl) { this.currentMarker = new maplibregl.Marker({ element: el }) .setLngLat([lon, lat]) .addTo(this.map) } } } /** * Dispatch custom search event * @param {Object} location - Selected location */ dispatchSearchEvent(location) { const event = new CustomEvent('location-search:selected', { detail: { location }, bubbles: true }) document.dispatchEvent(event) } /** * Show loading indicator */ showLoading() { this.clearResults() this.resultsContainer.innerHTML = `
Searching...
` this.resultsContainer.classList.remove('hidden') } /** * Show no results message */ showNoResults() { this.resultsContainer.innerHTML = `
No locations found
` this.resultsContainer.classList.remove('hidden') } /** * Show error message * @param {string} message - Error message */ showError(message) { this.resultsContainer.innerHTML = `
${message}
` this.resultsContainer.classList.remove('hidden') } /** * Show loading state while fetching visits * @param {string} locationName - Name of the location being searched */ showVisitsLoading(locationName) { this.resultsContainer.innerHTML = `
Searching for visits...
${this.escapeHtml(locationName)}
` this.resultsContainer.classList.remove('hidden') } /** * Display visits results * @param {Object} visitsData - Visits data from API * @param {Object} location - Selected location */ displayVisitsResults(visitsData, location) { // Store visits data for click handling this.currentVisitsData = visitsData if (!visitsData.locations || visitsData.locations.length === 0) { this.resultsContainer.innerHTML = `
📍
No visits found
No visits found for "${this.escapeHtml(location.name)}"
` this.resultsContainer.classList.remove('hidden') return } // Display visits grouped by location let html = `
Found ${visitsData.total_locations} location(s)
for "${this.escapeHtml(location.name)}"
` visitsData.locations.forEach((loc, index) => { html += this.buildLocationVisitsHtml(loc, index) }) this.resultsContainer.innerHTML = html this.resultsContainer.classList.remove('hidden') // Attach event listeners to year toggles and visit items this.attachYearToggleListeners() } /** * Build HTML for a location with its visits * @param {Object} location - Location with visits * @param {number} index - Location index * @returns {string} HTML string */ buildLocationVisitsHtml(location, index) { const visits = location.visits || [] if (visits.length === 0) return '' // Handle case where visits are sorted newest first const sortedVisits = [...visits].sort((a, b) => new Date(a.date) - new Date(b.date)) const firstVisit = sortedVisits[0] const lastVisit = sortedVisits[sortedVisits.length - 1] const visitsByYear = this.groupVisitsByYear(visits) // Use place_name, address, or coordinates as fallback const displayName = location.place_name || location.address || `Location (${location.coordinates?.[0]?.toFixed(4)}, ${location.coordinates?.[1]?.toFixed(4)})` return `
${this.escapeHtml(displayName)}
${location.address && location.place_name !== location.address ? `
${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('')}
` } /** * Group visits by year * @param {Array} visits - Array of visits * @returns {Object} Visits grouped by year */ groupVisitsByYear(visits) { const groups = {} visits.forEach(visit => { const year = new Date(visit.date).getFullYear().toString() if (!groups[year]) { groups[year] = [] } groups[year].push(visit) }) return groups } /** * Attach event listeners to year toggle elements */ attachYearToggleListeners() { const toggles = this.resultsContainer.querySelectorAll('.year-toggle') toggles.forEach(toggle => { toggle.addEventListener('click', (e) => { const locationIndex = e.currentTarget.dataset.locationIndex const year = e.currentTarget.dataset.year const visitsContainer = document.getElementById(`year-${locationIndex}-${year}`) const arrow = e.currentTarget.querySelector('.year-arrow') if (visitsContainer) { visitsContainer.classList.toggle('hidden') arrow.style.transform = visitsContainer.classList.contains('hidden') ? 'rotate(0deg)' : 'rotate(90deg)' } }) }) // Attach event listeners to individual visit items const visitItems = this.resultsContainer.querySelectorAll('.visit-item') visitItems.forEach(item => { item.addEventListener('click', (e) => { e.stopPropagation() const locationIndex = parseInt(item.dataset.locationIndex) const visitIndex = parseInt(item.dataset.visitIndex) this.handleVisitClick(locationIndex, visitIndex) }) }) } /** * Handle click on individual visit item * @param {number} locationIndex - Index of location in results * @param {number} visitIndex - Index of visit within location */ handleVisitClick(locationIndex, visitIndex) { if (!this.currentVisitsData || !this.currentVisitsData.locations) return const location = this.currentVisitsData.locations[locationIndex] if (!location || !location.visits) return const visit = location.visits[visitIndex] if (!visit) return // Fly to visit coordinates (more precise than location coordinates) const [lat, lon] = visit.coordinates || location.coordinates this.map.flyTo({ center: [lon, lat], zoom: 18, duration: 1000 }) // Extract visit details const visitDetails = visit.visit_details || {} const startTime = visitDetails.start_time || visit.date const endTime = visitDetails.end_time || visit.date const placeName = location.place_name || location.address || 'Unnamed Location' // Open create visit modal this.openCreateVisitModal({ name: placeName, latitude: lat, longitude: lon, started_at: startTime, ended_at: endTime }) } /** * Open modal to create a visit with prefilled data * @param {Object} visitData - Visit data to prefill */ openCreateVisitModal(visitData) { // Create modal HTML const modalId = 'create-visit-modal' // Remove existing modal if present const existingModal = document.getElementById(modalId) if (existingModal) { existingModal.remove() } const modal = document.createElement('div') modal.id = modalId modal.innerHTML = ` ` document.body.appendChild(modal) // Attach event listeners const form = modal.querySelector('form') const closeBtn = modal.querySelector('[data-action="close"]') const modalToggle = modal.querySelector(`#${modalId}-toggle`) const backdrop = modal.querySelector('.modal-backdrop') form.addEventListener('submit', (e) => { e.preventDefault() this.submitCreateVisit(form, modal) }) closeBtn.addEventListener('click', () => { modalToggle.checked = false setTimeout(() => modal.remove(), 300) }) backdrop.addEventListener('click', () => { modalToggle.checked = false setTimeout(() => modal.remove(), 300) }) } /** * Submit create visit form * @param {HTMLFormElement} form - Form element * @param {HTMLElement} modal - Modal element */ async submitCreateVisit(form, modal) { const submitBtn = form.querySelector('button[type="submit"]') const submitText = submitBtn.querySelector('.submit-text') const spinner = submitBtn.querySelector('.loading') // Disable submit button and show loading submitBtn.disabled = true submitText.classList.add('hidden') spinner.classList.remove('hidden') try { const formData = new FormData(form) const visitData = { name: formData.get('name'), latitude: parseFloat(formData.get('latitude')), longitude: parseFloat(formData.get('longitude')), started_at: formData.get('started_at'), ended_at: formData.get('ended_at'), status: 'confirmed' } const response = await this.service.createVisit(visitData) if (response.error) { throw new Error(response.error) } // Success - close modal and show success message const modalToggle = modal.querySelector('.modal-toggle') modalToggle.checked = false setTimeout(() => modal.remove(), 300) // Show success notification this.showSuccessNotification('Visit created successfully!') // Dispatch custom event for other components to react document.dispatchEvent(new CustomEvent('visit:created', { detail: { visit: response, coordinates: [visitData.longitude, visitData.latitude] } })) } catch (error) { console.error('Failed to create visit:', error) alert(`Failed to create visit: ${error.message}`) // Re-enable submit button submitBtn.disabled = false submitText.classList.remove('hidden') spinner.classList.add('hidden') } } /** * Show success notification * @param {string} message - Success message */ showSuccessNotification(message) { const notification = document.createElement('div') notification.className = 'toast toast-top toast-end z-[9999]' notification.innerHTML = `
✓ ${this.escapeHtml(message)}
` document.body.appendChild(notification) setTimeout(() => { notification.remove() }, 3000) } /** * Format datetime for input field (YYYY-MM-DDTHH:MM) * @param {string} dateString - Date string * @returns {string} Formatted datetime */ formatDateTimeForInput(dateString) { const date = new Date(dateString) const year = date.getFullYear() const month = String(date.getMonth() + 1).padStart(2, '0') const day = String(date.getDate()).padStart(2, '0') const hours = String(date.getHours()).padStart(2, '0') const minutes = String(date.getMinutes()).padStart(2, '0') return `${year}-${month}-${day}T${hours}:${minutes}` } /** * Format date in short format * @param {string} dateString - Date string * @returns {string} Formatted date */ formatDateShort(dateString) { const date = new Date(dateString) return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) } /** * Format date and time * @param {string} dateString - Date string * @returns {string} Formatted date and time */ formatDateTime(dateString) { const date = new Date(dateString) return date.toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' }) } /** * Escape HTML to prevent XSS * @param {string} str - String to escape * @returns {string} Escaped string */ escapeHtml(str) { if (!str) return '' const div = document.createElement('div') div.textContent = str return div.innerHTML } /** * Clear search results */ clearResults() { if (this.resultsContainer) { this.resultsContainer.innerHTML = '' this.resultsContainer.classList.add('hidden') } } /** * Clear search marker */ clearMarker() { if (this.currentMarker) { this.currentMarker.remove() this.currentMarker = null } } /** * Cleanup */ destroy() { clearTimeout(this.debounceTimer) this.clearMarker() this.clearResults() } }