document.addEventListener('DOMContentLoaded', async () => { const map = L.map('map').setView([42.9634, -85.6681], 10); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18, }).addTo(map); // Get API configuration let config = { hasGoogleMaps: false, googleMapsApiKey: null }; try { const configResponse = await fetch('/api/config'); config = await configResponse.json(); console.log('🔧 API Configuration:', config.hasGoogleMaps ? 'Google Maps enabled' : 'Using Nominatim fallback'); } catch (error) { console.warn('Failed to load API configuration, using fallback'); } const locationForm = document.getElementById('location-form'); const messageDiv = document.getElementById('message'); const submitBtn = document.getElementById('submit-btn'); const submitText = document.getElementById('submit-text'); const submitLoading = document.getElementById('submit-loading'); const addressInput = document.getElementById('address'); const autocompleteList = document.getElementById('autocomplete-list'); let autocompleteTimeout; let selectedIndex = -1; let autocompleteResults = []; let currentMarkers = []; let updateInterval; let googleAutocompleteService = null; let googleGeocoderService = null; // Initialize Google Services if API key is available if (config.hasGoogleMaps) { try { // Load Google Maps API await loadGoogleMapsAPI(config.googleMapsApiKey); googleAutocompleteService = new google.maps.places.AutocompleteService(); googleGeocoderService = new google.maps.Geocoder(); console.log('✅ Google Maps services initialized'); } catch (error) { console.warn('❌ Failed to initialize Google Maps, falling back to Nominatim'); } } function loadGoogleMapsAPI(apiKey) { return new Promise((resolve, reject) => { if (window.google) { resolve(); return; } const script = document.createElement('script'); script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places`; script.async = true; script.defer = true; script.onload = resolve; script.onerror = reject; document.head.appendChild(script); }); } const clearMarkers = () => { currentMarkers.forEach(marker => { map.removeLayer(marker); }); currentMarkers = []; }; const showMarkers = locations => { clearMarkers(); locations.forEach(({ latitude, longitude, address, description, created_at }) => { if (latitude && longitude) { const timeAgo = getTimeAgo(created_at); const marker = L.marker([latitude, longitude]) .addTo(map) .bindPopup(`${address}
${description || 'No additional details'}
Reported ${timeAgo}`); currentMarkers.push(marker); } }); }; const getTimeAgo = (timestamp) => { const now = new Date(); const reportTime = new Date(timestamp); const diffInMinutes = Math.floor((now - reportTime) / (1000 * 60)); if (diffInMinutes < 1) return 'just now'; if (diffInMinutes < 60) return `${diffInMinutes} minute${diffInMinutes !== 1 ? 's' : ''} ago`; const diffInHours = Math.floor(diffInMinutes / 60); if (diffInHours < 24) return `${diffInHours} hour${diffInHours !== 1 ? 's' : ''} ago`; return 'over a day ago'; }; const refreshLocations = () => { fetch('/api/locations') .then(res => res.json()) .then(locations => { showMarkers(locations); const countElement = document.getElementById('location-count'); countElement.textContent = `${locations.length} active report${locations.length !== 1 ? 's' : ''}`; const now = new Date(); const timeStr = now.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }); countElement.title = `Last updated: ${timeStr}`; }) .catch(err => { console.error('Error fetching locations:', err); document.getElementById('location-count').textContent = 'Error loading locations'; }); }; refreshLocations(); updateInterval = setInterval(refreshLocations, 30000); // Google Maps Geocoding (Fast!) const googleGeocode = async (address) => { const startTime = performance.now(); console.log(`🚀 Google Geocoding: "${address}"`); return new Promise((resolve, reject) => { googleGeocoderService.geocode({ address: address, componentRestrictions: { country: 'US', administrativeArea: 'MI' }, region: 'us' }, (results, status) => { const time = (performance.now() - startTime).toFixed(2); if (status === 'OK' && results.length > 0) { const result = results[0]; console.log(`✅ Google geocoding successful in ${time}ms`); console.log(`📍 Found: ${result.formatted_address}`); resolve({ lat: result.geometry.location.lat(), lon: result.geometry.location.lng(), display_name: result.formatted_address }); } else { console.log(`❌ Google geocoding failed: ${status} (${time}ms)`); reject(new Error(`Google geocoding failed: ${status}`)); } }); }); }; // Nominatim Fallback (Slower but free) const nominatimGeocode = async (address) => { const startTime = performance.now(); console.log(`🐌 Nominatim fallback: "${address}"`); const searches = [ address, `${address}, Michigan`, `${address}, MI`, `${address}, Grand Rapids, MI` ]; for (const query of searches) { try { const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=1&countrycodes=us`); const data = await response.json(); if (data && data.length > 0) { const time = (performance.now() - startTime).toFixed(2); console.log(`✅ Nominatim successful in ${time}ms`); return data[0]; } } catch (error) { console.warn(`Nominatim query failed: ${query}`); } } const time = (performance.now() - startTime).toFixed(2); console.error(`💥 All geocoding failed after ${time}ms`); throw new Error('Address not found'); }; // Main geocoding function const geocodeAddress = async (address) => { if (googleGeocoderService) { try { return await googleGeocode(address); } catch (error) { console.warn('Google geocoding failed, trying Nominatim fallback'); return await nominatimGeocode(address); } } else { return await nominatimGeocode(address); } }; // Google Places Autocomplete (Super fast!) const googleAutocomplete = async (query) => { const startTime = performance.now(); console.log(`⚡ Google Autocomplete: "${query}"`); return new Promise((resolve) => { googleAutocompleteService.getPlacePredictions({ input: query, componentRestrictions: { country: 'us' }, types: ['address'], location: new google.maps.LatLng(42.9634, -85.6681), radius: 50000 // 50km around Grand Rapids }, (predictions, status) => { const time = (performance.now() - startTime).toFixed(2); if (status === google.maps.places.PlacesServiceStatus.OK && predictions) { const michiganResults = predictions.filter(p => p.description.toLowerCase().includes('mi,') || p.description.toLowerCase().includes('michigan') ); console.log(`⚡ Google autocomplete: ${michiganResults.length} results in ${time}ms`); resolve(michiganResults.slice(0, 5).map(p => ({ display_name: p.description, place_id: p.place_id }))); } else { console.log(`❌ Google autocomplete failed: ${status} (${time}ms)`); resolve([]); } }); }); }; // Nominatim Autocomplete Fallback const nominatimAutocomplete = async (query) => { console.log(`🐌 Nominatim autocomplete fallback: "${query}"`); try { const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=5&countrycodes=us&addressdetails=1`); const data = await response.json(); return data.filter(item => item.display_name.toLowerCase().includes('michigan') || item.display_name.toLowerCase().includes(', mi,') ).slice(0, 5); } catch (error) { console.error('Nominatim autocomplete failed:', error); return []; } }; // Main autocomplete function const fetchAddressSuggestions = async (query) => { if (query.length < 3) { hideAutocomplete(); return; } let results = []; if (googleAutocompleteService) { try { results = await googleAutocomplete(query); } catch (error) { console.warn('Google autocomplete failed, trying Nominatim'); } } if (results.length === 0) { results = await nominatimAutocomplete(query); } autocompleteResults = results; showAutocomplete(autocompleteResults); }; // Form submission locationForm.addEventListener('submit', e => { e.preventDefault(); const address = document.getElementById('address').value; const description = document.getElementById('description').value; if (!address) return; submitBtn.disabled = true; submitText.style.display = 'none'; submitLoading.style.display = 'inline'; geocodeAddress(address) .then(result => { const { lat, lon } = result; return fetch('/api/locations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ address, latitude: parseFloat(lat), longitude: parseFloat(lon), description, }), }); }) .then(res => res.json()) .then(location => { refreshLocations(); messageDiv.textContent = 'Location reported successfully!'; messageDiv.className = 'message success'; locationForm.reset(); }) .catch(err => { console.error('Error reporting location:', err); messageDiv.textContent = 'Error reporting location.'; messageDiv.className = 'message error'; }) .finally(() => { submitBtn.disabled = false; submitText.style.display = 'inline'; submitLoading.style.display = 'none'; messageDiv.style.display = 'block'; setTimeout(() => { messageDiv.style.display = 'none'; }, 3000); }); }); // Autocomplete UI functions (same as before) const showAutocomplete = (results) => { if (results.length === 0) { hideAutocomplete(); return; } autocompleteList.innerHTML = ''; selectedIndex = -1; results.forEach((result, index) => { const item = document.createElement('div'); item.className = 'autocomplete-item'; item.textContent = result.display_name; item.addEventListener('click', () => selectAddress(result)); autocompleteList.appendChild(item); }); autocompleteList.style.display = 'block'; }; const hideAutocomplete = () => { autocompleteList.style.display = 'none'; selectedIndex = -1; }; const selectAddress = (result) => { addressInput.value = result.display_name; hideAutocomplete(); addressInput.focus(); }; const updateSelection = (direction) => { const items = autocompleteList.querySelectorAll('.autocomplete-item'); if (items.length === 0) return; items[selectedIndex]?.classList.remove('selected'); if (direction === 'down') { selectedIndex = selectedIndex < items.length - 1 ? selectedIndex + 1 : 0; } else { selectedIndex = selectedIndex > 0 ? selectedIndex - 1 : items.length - 1; } items[selectedIndex].classList.add('selected'); items[selectedIndex].scrollIntoView({ block: 'nearest' }); }; // Event listeners addressInput.addEventListener('input', (e) => { clearTimeout(autocompleteTimeout); const query = e.target.value.trim(); if (query.length < 3) { hideAutocomplete(); return; } autocompleteTimeout = setTimeout(() => { fetchAddressSuggestions(query); }, 200); // Faster debounce with Google APIs }); addressInput.addEventListener('keydown', (e) => { const isListVisible = autocompleteList.style.display === 'block'; switch (e.key) { case 'ArrowDown': if (isListVisible) { e.preventDefault(); updateSelection('down'); } break; case 'ArrowUp': if (isListVisible) { e.preventDefault(); updateSelection('up'); } break; case 'Enter': if (isListVisible && selectedIndex >= 0) { e.preventDefault(); selectAddress(autocompleteResults[selectedIndex]); } break; case 'Escape': hideAutocomplete(); break; } }); document.addEventListener('click', (e) => { if (!e.target.closest('.autocomplete-container')) { hideAutocomplete(); } }); window.addEventListener('beforeunload', () => { if (updateInterval) { clearInterval(updateInterval); } }); });