document.addEventListener('DOMContentLoaded', () => { 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); 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; const clearMarkers = () => { currentMarkers.forEach(marker => { map.removeLayer(marker); }); currentMarkers = []; }; // Create custom icons for different report types const createCustomIcon = (isPersistent = false) => { const iconColor = isPersistent ? '#28a745' : '#dc3545'; // Green for persistent, red for temporary const iconSymbol = isPersistent ? '🔒' : '⚠️'; // Lock for persistent, warning for temporary return L.divIcon({ className: 'custom-marker', html: `
${iconSymbol}
`, iconSize: [30, 30], iconAnchor: [15, 15], popupAnchor: [0, -15] }); }; const showMarkers = locations => { // Clear existing markers first clearMarkers(); locations.forEach(({ latitude, longitude, address, description, created_at, persistent }) => { if (latitude && longitude) { const timeAgo = getTimeAgo(created_at); const isPersistent = !!persistent; const persistentText = isPersistent ? '
📌 Persistent Report' : ''; const marker = L.marker([latitude, longitude], { icon: createCustomIcon(isPersistent) }) .addTo(map) .bindPopup(`${address}
${description || 'No additional details'}
Reported ${timeAgo}${persistentText}`); 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' : ''}`; // Add visual indicator of last update 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'; }); }; // Initial load refreshLocations(); // Set up real-time updates every 30 seconds updateInterval = setInterval(refreshLocations, 30000); 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'; // Try multiple geocoding strategies for intersections const tryGeocode = async (query) => { const cleanQuery = query.trim(); const startTime = performance.now(); console.log(`🔍 Starting geocoding for: "${cleanQuery}"`); // Generate multiple search variations const searches = [ cleanQuery, // Original query cleanQuery.replace(' & ', ' and '), // Replace & with 'and' cleanQuery.replace(' and ', ' & '), // Replace 'and' with & cleanQuery.replace(' at ', ' & '), // Replace 'at' with & `${cleanQuery}, Michigan`, // Add Michigan if not present `${cleanQuery}, MI`, // Add MI if not present ]; // Add street type variations const streetVariations = []; searches.forEach(search => { streetVariations.push(search); streetVariations.push(search.replace(' St ', ' Street ')); streetVariations.push(search.replace(' Street ', ' St ')); streetVariations.push(search.replace(' Ave ', ' Avenue ')); streetVariations.push(search.replace(' Avenue ', ' Ave ')); streetVariations.push(search.replace(' Rd ', ' Road ')); streetVariations.push(search.replace(' Road ', ' Rd ')); streetVariations.push(search.replace(' Blvd ', ' Boulevard ')); streetVariations.push(search.replace(' Boulevard ', ' Blvd ')); }); // Remove duplicates const uniqueSearches = [...new Set(streetVariations)]; console.log(`📋 Generated ${uniqueSearches.length} search variations`); let attemptCount = 0; for (const searchQuery of uniqueSearches) { attemptCount++; const queryStartTime = performance.now(); try { console.log(`🌐 Attempt ${attemptCount}: "${searchQuery}"`); const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&extratags=1&q=${encodeURIComponent(searchQuery)}`); const data = await response.json(); const queryTime = (performance.now() - queryStartTime).toFixed(2); if (data && data.length > 0) { // Prefer results that are in Michigan/Grand Rapids const michiganResults = data.filter(item => item.display_name.toLowerCase().includes('michigan') || item.display_name.toLowerCase().includes('grand rapids') || item.display_name.toLowerCase().includes(', mi') ); const result = michiganResults.length > 0 ? michiganResults[0] : data[0]; const totalTime = (performance.now() - startTime).toFixed(2); console.log(`✅ Geocoding successful in ${totalTime}ms (query: ${queryTime}ms)`); console.log(`📍 Found: ${result.display_name}`); console.log(`🎯 Coordinates: ${result.lat}, ${result.lon}`); return result; } else { console.log(`❌ No results found (${queryTime}ms)`); } } catch (error) { const queryTime = (performance.now() - queryStartTime).toFixed(2); console.warn(`🚫 Geocoding failed for: "${searchQuery}" (${queryTime}ms)`, error); } } // If intersection search fails, try individual streets for approximate location if (cleanQuery.includes('&') || cleanQuery.includes(' and ')) { console.log(`🔄 Trying fallback strategy for intersection`); const streets = cleanQuery.split(/\s+(?:&|and)\s+/i); if (streets.length >= 2) { const firstStreet = streets[0].trim() + ', Grand Rapids, MI'; const fallbackStartTime = performance.now(); try { console.log(`🌐 Fallback: "${firstStreet}"`); const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(firstStreet)}`); const data = await response.json(); const fallbackTime = (performance.now() - fallbackStartTime).toFixed(2); if (data && data.length > 0) { const totalTime = (performance.now() - startTime).toFixed(2); console.log(`⚠️ Using approximate location from first street (${totalTime}ms total)`); console.log(`📍 Approximate: ${data[0].display_name}`); return data[0]; } else { console.log(`❌ Fallback failed - no results (${fallbackTime}ms)`); } } catch (error) { const fallbackTime = (performance.now() - fallbackStartTime).toFixed(2); console.warn(`🚫 Fallback search failed: "${firstStreet}" (${fallbackTime}ms)`, error); } } } const totalTime = (performance.now() - startTime).toFixed(2); console.error(`💥 All geocoding strategies failed after ${totalTime}ms`); throw new Error('Address not found with any search strategy'); }; tryGeocode(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 => { // Immediately refresh all locations to show the new one 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 functionality const fetchAddressSuggestions = async (query) => { if (query.length < 3) { hideAutocomplete(); return; } try { // Create search variations for better intersection handling const searchQueries = [ query, query.replace(' & ', ' and '), query.replace(' and ', ' & '), `${query}, Grand Rapids, MI`, `${query}, Michigan` ]; let allResults = []; // Try each search variation for (const searchQuery of searchQueries) { try { const response = await fetch( `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(searchQuery)}&limit=3&countrycodes=us&addressdetails=1` ); const data = await response.json(); if (data && data.length > 0) { allResults = allResults.concat(data); } } catch (error) { console.warn(`Autocomplete search failed for: ${searchQuery}`); } } // Filter and deduplicate results const michiganResults = allResults.filter(item => { return item.display_name.toLowerCase().includes('michigan') || item.display_name.toLowerCase().includes(', mi,') || item.display_name.toLowerCase().includes('grand rapids'); }); // Remove duplicates based on display_name const uniqueResults = michiganResults.filter((item, index, arr) => arr.findIndex(other => other.display_name === item.display_name) === index ); autocompleteResults = uniqueResults.slice(0, 5); showAutocomplete(autocompleteResults); } catch (error) { console.error('Error fetching address suggestions:', error); hideAutocomplete(); } }; 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; // Remove previous selection items[selectedIndex]?.classList.remove('selected'); // Update index if (direction === 'down') { selectedIndex = selectedIndex < items.length - 1 ? selectedIndex + 1 : 0; } else { selectedIndex = selectedIndex > 0 ? selectedIndex - 1 : items.length - 1; } // Add new selection items[selectedIndex].classList.add('selected'); items[selectedIndex].scrollIntoView({ block: 'nearest' }); }; // Event listeners for autocomplete addressInput.addEventListener('input', (e) => { clearTimeout(autocompleteTimeout); const query = e.target.value.trim(); if (query.length < 3) { hideAutocomplete(); return; } // Debounce the API calls autocompleteTimeout = setTimeout(() => { fetchAddressSuggestions(query); }, 300); }); 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; } }); // Hide autocomplete when clicking outside document.addEventListener('click', (e) => { if (!e.target.closest('.autocomplete-container')) { hideAutocomplete(); } }); // Cleanup interval when page is unloaded window.addEventListener('beforeunload', () => { if (updateInterval) { clearInterval(updateInterval); } }); });