/** * Base class for all map implementations * Provides common functionality for map display, location management, and UI interactions */ class MapBase { constructor() { // Map instance (to be initialized by subclasses) this.map = null; this.currentMarkers = []; // Autocomplete state this.autocompleteTimeout = null; this.selectedIndex = -1; this.autocompleteResults = []; // Update interval this.updateInterval = null; // DOM elements this.addressInput = document.getElementById('address'); this.descriptionInput = document.getElementById('description'); this.submitButton = document.querySelector('.submit-btn'); this.messageElement = document.getElementById('message'); this.locationCountElement = document.getElementById('location-count'); this.lastUpdateElement = document.getElementById('last-update'); this.autocompleteContainer = document.getElementById('autocomplete'); this.locationsContainer = document.querySelector('.locations-container'); // Initialize event listeners this.initializeEventListeners(); // Start automatic refresh this.startAutoRefresh(); } /** * Initialize common event listeners */ initializeEventListeners() { // Form submission document.getElementById('location-form').addEventListener('submit', (e) => { e.preventDefault(); this.handleSubmit(); }); // Address input with autocomplete this.addressInput.addEventListener('input', (e) => { this.handleAddressInput(e); }); // Keyboard navigation for autocomplete this.addressInput.addEventListener('keydown', (e) => { this.handleAutocompleteKeydown(e); }); // Click outside to close autocomplete document.addEventListener('click', (e) => { if (!e.target.closest('.search-container')) { this.hideAutocomplete(); } }); // Refresh button const refreshButton = document.getElementById('refresh-button'); if (refreshButton) { refreshButton.addEventListener('click', () => { this.refreshLocations(); }); } } /** * Handle address input for autocomplete */ handleAddressInput(e) { clearTimeout(this.autocompleteTimeout); const query = e.target.value.trim(); if (query.length < 3) { this.hideAutocomplete(); return; } this.autocompleteTimeout = setTimeout(() => { this.searchAddress(query); }, 300); } /** * Handle keyboard navigation in autocomplete */ handleAutocompleteKeydown(e) { if (!this.autocompleteContainer.classList.contains('show')) return; switch(e.key) { case 'ArrowDown': e.preventDefault(); this.selectedIndex = Math.min(this.selectedIndex + 1, this.autocompleteResults.length - 1); this.updateSelection(); break; case 'ArrowUp': e.preventDefault(); this.selectedIndex = Math.max(this.selectedIndex - 1, -1); this.updateSelection(); break; case 'Enter': e.preventDefault(); if (this.selectedIndex >= 0) { this.selectAddress(this.selectedIndex); } break; case 'Escape': this.hideAutocomplete(); break; } } /** * Search for addresses - to be implemented by subclasses */ async searchAddress(query) { // To be implemented by subclasses throw new Error('searchAddress must be implemented by subclass'); } /** * Show autocomplete results */ showAutocomplete(results) { this.autocompleteResults = results; this.selectedIndex = -1; if (results.length === 0) { this.hideAutocomplete(); return; } this.autocompleteContainer.innerHTML = results.map((result, index) => `
${result.display_name || result.formatted_address || result.name}
`).join(''); // Add click handlers this.autocompleteContainer.querySelectorAll('.autocomplete-item').forEach(item => { item.addEventListener('click', () => { this.selectAddress(parseInt(item.dataset.index)); }); }); this.autocompleteContainer.classList.add('show'); } /** * Hide autocomplete dropdown */ hideAutocomplete() { this.autocompleteContainer.classList.remove('show'); this.autocompleteContainer.innerHTML = ''; this.selectedIndex = -1; } /** * Update visual selection in autocomplete */ updateSelection() { const items = this.autocompleteContainer.querySelectorAll('.autocomplete-item'); items.forEach((item, index) => { item.classList.toggle('selected', index === this.selectedIndex); }); } /** * Select an address from autocomplete */ selectAddress(index) { const result = this.autocompleteResults[index]; if (result) { this.addressInput.value = result.display_name || result.formatted_address || result.name; this.addressInput.dataset.lat = result.lat || result.latitude; this.addressInput.dataset.lon = result.lon || result.longitude; this.hideAutocomplete(); } } /** * Handle form submission */ async handleSubmit() { const address = this.addressInput.value.trim(); const description = this.descriptionInput.value.trim(); const lat = parseFloat(this.addressInput.dataset.lat); const lon = parseFloat(this.addressInput.dataset.lon); if (!address) { this.showMessage('Please enter an address', 'error'); return; } if (!lat || !lon || isNaN(lat) || isNaN(lon)) { this.showMessage('Please select a valid address from the suggestions', 'error'); return; } // Disable submit button this.submitButton.disabled = true; this.submitButton.innerHTML = ' Submitting...'; try { const response = await fetch('/api/locations', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ address, latitude: lat, longitude: lon, description }) }); const data = await response.json(); if (response.ok) { this.showMessage('Location reported successfully!', 'success'); this.resetForm(); this.refreshLocations(); } else { this.handleSubmitError(data); } } catch (error) { console.error('Error submitting location:', error); this.showMessage('Error submitting location. Please try again.', 'error'); } finally { this.submitButton.disabled = false; this.submitButton.innerHTML = ' Report Ice'; } } /** * Handle submission errors */ handleSubmitError(data) { if (data.error === 'Submission rejected' && data.message) { // Profanity rejection this.showMessage(data.message, 'error', 10000); } else { this.showMessage(data.error || 'Error submitting location', 'error'); } } /** * Reset form after successful submission */ resetForm() { this.addressInput.value = ''; this.addressInput.dataset.lat = ''; this.addressInput.dataset.lon = ''; this.descriptionInput.value = ''; } /** * Show a message to the user */ showMessage(text, type = 'info', duration = 5000) { this.messageElement.textContent = text; this.messageElement.className = `message ${type} show`; setTimeout(() => { this.messageElement.classList.remove('show'); }, duration); } /** * Clear all markers from the map */ clearMarkers() { this.currentMarkers.forEach(marker => { if (this.map && marker) { this.map.removeLayer(marker); } }); this.currentMarkers = []; } /** * Create a marker - to be implemented by subclasses */ createMarker(location) { // To be implemented by subclasses throw new Error('createMarker must be implemented by subclass'); } /** * Refresh locations from the server */ async refreshLocations() { try { const response = await fetch('/api/locations'); const locations = await response.json(); this.displayLocations(locations); this.updateLocationCount(locations.length); } catch (error) { console.error('Error fetching locations:', error); this.showMessage('Error loading locations', 'error'); } } /** * Display locations on the map */ displayLocations(locations) { this.clearMarkers(); locations.forEach(location => { if (location.latitude && location.longitude) { const marker = this.createMarker(location); if (marker) { this.currentMarkers.push(marker); } } }); // Update locations list if container exists if (this.locationsContainer) { this.updateLocationsList(locations); } } /** * Update the locations list (for table view) */ updateLocationsList(locations) { if (!this.locationsContainer) return; if (locations.length === 0) { this.locationsContainer.innerHTML = '

No ice reports in the last 48 hours

'; return; } this.locationsContainer.innerHTML = locations.map(location => `

${location.address}

${location.description ? `

${location.description}

` : ''}

${window.getTimeAgo ? window.getTimeAgo(location.created_at) : ''}

`).join(''); } /** * Update location count display */ updateLocationCount(count) { if (this.locationCountElement) { const plural = count !== 1 ? 's' : ''; this.locationCountElement.textContent = `${count} report${plural}`; } if (this.lastUpdateElement) { this.lastUpdateElement.textContent = `Last updated: ${new Date().toLocaleTimeString()}`; } } /** * Start automatic refresh of locations */ startAutoRefresh() { // Initial load this.refreshLocations(); // Refresh every 30 seconds this.updateInterval = setInterval(() => { this.refreshLocations(); }, 30000); } /** * Stop automatic refresh */ stopAutoRefresh() { if (this.updateInterval) { clearInterval(this.updateInterval); this.updateInterval = null; } } /** * Initialize the map - to be implemented by subclasses */ initializeMap() { throw new Error('initializeMap must be implemented by subclass'); } } // Export for use in other files window.MapBase = MapBase;