/** * MapBox implementation with table view and theme toggle using MapBase */ class MapBoxApp extends MapBase { constructor() { super(); this.config = { hasMapbox: false, mapboxAccessToken: null }; this.currentView = 'map'; // 'map' or 'table' this.initializeConfig(); this.initializeMapBoxFeatures(); } /** * Initialize API configuration */ async initializeConfig() { try { const configResponse = await fetch('/api/config'); this.config = await configResponse.json(); console.log('πŸ”§ API Configuration:', this.config.hasMapbox ? 'MapBox enabled' : 'Using Nominatim fallback'); } catch (error) { console.warn('Failed to load API configuration, using fallback'); } this.initializeMap(); } /** * Initialize MapBox-specific features */ initializeMapBoxFeatures() { this.initializeViewToggle(); this.initializeThemeToggle(); } /** * Initialize the Leaflet map */ initializeMap() { this.map = L.map('map').setView([42.9634, -85.6681], 10); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18, }).addTo(this.map); } /** * Initialize view toggle functionality */ initializeViewToggle() { const mapViewBtn = document.getElementById('map-view-btn'); const tableViewBtn = document.getElementById('table-view-btn'); if (mapViewBtn && tableViewBtn) { mapViewBtn.addEventListener('click', () => this.switchView('map')); tableViewBtn.addEventListener('click', () => this.switchView('table')); } } /** * Initialize theme toggle functionality */ initializeThemeToggle() { const themeToggle = document.getElementById('theme-toggle'); if (themeToggle) { themeToggle.addEventListener('click', () => this.toggleTheme()); } // Apply saved theme on load const savedTheme = localStorage.getItem('theme'); if (savedTheme) { document.body.dataset.theme = savedTheme; } } /** * Toggle between light and dark theme */ toggleTheme() { const currentTheme = document.body.dataset.theme || 'light'; const newTheme = currentTheme === 'light' ? 'dark' : 'light'; document.body.dataset.theme = newTheme; localStorage.setItem('theme', newTheme); } /** * Switch between map and table view */ switchView(viewType) { this.currentView = viewType; const mapView = document.getElementById('map-view'); const tableView = document.getElementById('table-view'); const mapViewBtn = document.getElementById('map-view-btn'); const tableViewBtn = document.getElementById('table-view-btn'); if (viewType === 'map') { mapView.style.display = 'block'; tableView.style.display = 'none'; mapViewBtn.classList.add('active'); tableViewBtn.classList.remove('active'); // Invalidate map size after showing setTimeout(() => this.map.invalidateSize(), 100); } else { mapView.style.display = 'none'; tableView.style.display = 'block'; mapViewBtn.classList.remove('active'); tableViewBtn.classList.add('active'); } } /** * Create custom marker for location */ createMarker(location) { const isPersistent = !!location.persistent; const timeAgo = window.getTimeAgo ? window.getTimeAgo(location.created_at) : ''; const persistentText = isPersistent ? '
πŸ“Œ Persistent Report' : ''; const customIcon = this.createCustomIcon(isPersistent); const marker = L.marker([location.latitude, location.longitude], { icon: customIcon }) .addTo(this.map) .bindPopup(`${location.address}
${location.description || 'No additional details'}
Reported ${timeAgo}${persistentText}`); return marker; } /** * Create custom icon for markers */ createCustomIcon(isPersistent = false) { const iconColor = isPersistent ? '#28a745' : '#dc3545'; const iconSymbol = isPersistent ? 'πŸ”’' : '⚠️'; return L.divIcon({ className: 'custom-marker', html: `
${iconSymbol}
`, iconSize: [30, 30], iconAnchor: [15, 15], popupAnchor: [0, -15] }); } /** * Display locations (override to handle table view) */ displayLocations(locations) { super.displayLocations(locations); // Update table view if currently active if (this.currentView === 'table') { this.renderTable(locations); } } /** * Render table view */ renderTable(locations) { const reportsTableBody = document.getElementById('reports-tbody'); const tableLocationCount = document.getElementById('table-location-count'); if (!reportsTableBody || !locations || locations.length === 0) { if (reportsTableBody) { reportsTableBody.innerHTML = 'No active reports'; } if (tableLocationCount) { tableLocationCount.textContent = '0 active reports'; } return; } // Sort by most recent first const sortedLocations = [...locations].sort((a, b) => new Date(b.created_at) - new Date(a.created_at) ); const tableHTML = sortedLocations.map(location => { const timeAgo = window.getTimeAgo ? window.getTimeAgo(location.created_at) : ''; const timeRemaining = this.getTimeRemaining(location.created_at, location.persistent); const remainingClass = this.getRemainingClass(location.created_at, location.persistent); const reportedTime = new Date(location.created_at).toLocaleString(); return ` ${location.address} ${location.description || 'No additional details'} ${timeAgo} ${timeRemaining} `; }).join(''); reportsTableBody.innerHTML = tableHTML; tableLocationCount.textContent = `${locations.length} active report${locations.length !== 1 ? 's' : ''}`; } /** * Get time remaining for a report */ getTimeRemaining(createdAt, isPersistent) { if (isPersistent) { return '♾️ Persistent'; } const created = new Date(createdAt); const now = new Date(); const elapsed = now - created; const remaining = (48 * 60 * 60 * 1000) - elapsed; // 48 hours in ms if (remaining <= 0) { return '⏰ Expired'; } const hours = Math.floor(remaining / (60 * 60 * 1000)); const minutes = Math.floor((remaining % (60 * 60 * 1000)) / (60 * 1000)); if (hours >= 24) { const days = Math.floor(hours / 24); const remainingHours = hours % 24; return `${days}d ${remainingHours}h`; } return `${hours}h ${minutes}m`; } /** * Get CSS class for time remaining */ getRemainingClass(createdAt, isPersistent) { if (isPersistent) { return 'persistent'; } const created = new Date(createdAt); const now = new Date(); const elapsed = now - created; const remaining = (48 * 60 * 60 * 1000) - elapsed; if (remaining <= 0) { return 'expired'; } else if (remaining < 6 * 60 * 60 * 1000) { // Less than 6 hours return 'expiring-soon'; } else if (remaining < 24 * 60 * 60 * 1000) { // Less than 24 hours return 'expiring-today'; } return 'fresh'; } /** * Search for addresses using MapBox or Nominatim fallback */ async searchAddress(query) { try { let results = []; // Try MapBox first if available if (this.config.hasMapbox && this.config.mapboxAccessToken) { try { results = await this.searchMapBox(query); if (results.length > 0) { this.showAutocomplete(results); return; } } catch (error) { console.warn('MapBox search failed, falling back to Nominatim:', error); } } // Fallback to Nominatim results = await this.searchNominatim(query); this.showAutocomplete(results); } catch (error) { console.error('Error searching addresses:', error); this.showAutocomplete([]); } } /** * Search using MapBox API */ async searchMapBox(query) { const response = await fetch(`https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(query)}.json?access_token=${this.config.mapboxAccessToken}&country=us&proximity=-85.6681,42.9634&limit=5`); const data = await response.json(); if (data.features && data.features.length > 0) { return data.features.map(feature => ({ display_name: feature.place_name, lat: feature.center[1], lon: feature.center[0] })); } return []; } /** * Search using Nominatim as fallback */ async searchNominatim(query) { const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&extratags=1&limit=5&q=${encodeURIComponent(query + ', Michigan')}`); const data = await response.json(); if (data && data.length > 0) { return data.slice(0, 5); } return []; } /** * Handle submission errors (override to handle profanity rejection) */ handleSubmitError(data) { if (data.error === 'Submission rejected' && data.message) { // Enhanced profanity rejection handling this.showMessage(data.message, 'error', 10000); } else { super.handleSubmitError(data); } } } // Initialize the app when DOM is loaded document.addEventListener('DOMContentLoaded', () => { new MapBoxApp(); });